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#[doc(hidden)]
232#[derive(Debug, Clone, Copy, PartialEq)]
233pub enum ConstantNumber {
234 Signed(i64),
235 Unsigned(u64),
236 Float(f64),
237}
238
239impl ConstantNumber {
240 pub fn as_f64(self) -> f64 {
241 match self {
242 Self::Signed(value) => value as f64,
243 Self::Unsigned(value) => value as f64,
244 Self::Float(value) => value,
245 }
246 }
247}
248
249#[doc(hidden)]
251#[derive(Serialize, Deserialize, Debug, Clone, Copy, Default, PartialEq, Eq)]
252#[serde(rename_all = "lowercase")]
253pub enum ConstantStorage {
254 I8,
255 I16,
256 I32,
257 I64,
258 Isize,
259 U8,
260 U16,
261 U32,
262 U64,
263 Usize,
264 #[default]
265 F32,
266 F64,
267}
268
269impl ConstantStorage {
270 pub const fn rust_type(self) -> &'static str {
271 match self {
272 Self::I8 => "i8",
273 Self::I16 => "i16",
274 Self::I32 => "i32",
275 Self::I64 => "i64",
276 Self::Isize => "isize",
277 Self::U8 => "u8",
278 Self::U16 => "u16",
279 Self::U32 => "u32",
280 Self::U64 => "u64",
281 Self::Usize => "usize",
282 Self::F32 => "f32",
283 Self::F64 => "f64",
284 }
285 }
286
287 pub const fn supports_quantity(self) -> bool {
288 matches!(self, Self::F32 | Self::F64)
289 }
290}
291
292#[doc(hidden)]
294#[derive(Serialize, Deserialize, Debug, Clone)]
295pub struct ConstantConfig {
296 id: String,
297 #[serde(default, deserialize_with = "deserialize_constant_module")]
298 module: Option<String>,
299 #[serde(default)]
300 storage: Option<ConstantStorage>,
301 quantity: Option<cu29_units::constant::Quantity>,
302 unit: Option<cu29_units::constant::Unit>,
303 value: Option<Value>,
304 #[serde(rename = "type")]
305 rust_type: Option<String>,
306 expression: Option<String>,
307}
308
309fn deserialize_constant_module<'de, D>(deserializer: D) -> Result<Option<String>, D::Error>
310where
311 D: Deserializer<'de>,
312{
313 Option::<String>::deserialize(deserializer).map(|module| {
314 module.map(|module| {
315 module
316 .chars()
317 .filter(|character| !character.is_whitespace())
318 .collect()
319 })
320 })
321}
322
323impl ConstantConfig {
324 pub const DEFAULT_MODULE: &'static str = "constants";
325
326 pub fn id(&self) -> &str {
327 &self.id
328 }
329
330 pub fn module_path(&self) -> &str {
331 self.module.as_deref().unwrap_or(Self::DEFAULT_MODULE)
332 }
333
334 pub fn qualified_id(&self) -> String {
335 format!("{}::{}", self.module_path(), self.id)
336 }
337
338 pub const fn storage(&self) -> ConstantStorage {
339 match self.storage {
340 Some(storage) => storage,
341 None => ConstantStorage::F32,
342 }
343 }
344
345 pub const fn quantity(&self) -> Option<cu29_units::constant::Quantity> {
346 self.quantity
347 }
348
349 pub const fn explicit_unit(&self) -> Option<cu29_units::constant::Unit> {
350 self.unit
351 }
352
353 pub fn expression_definition(&self) -> Option<(&str, &str)> {
354 self.rust_type.as_deref().zip(self.expression.as_deref())
355 }
356
357 pub fn resolved_unit(&self) -> Result<Option<cu29_units::constant::Unit>, String> {
358 let Some(quantity) = self.quantity else {
359 return Ok(None);
360 };
361 if let Some(unit) = self.unit {
362 return Ok(Some(unit));
363 }
364 let definition = cu29_units::constant::definition(quantity).ok_or_else(|| {
365 format!(
366 "Constant '{}' uses quantity '{}' which is missing from the unit catalogue",
367 self.id,
368 quantity.name()
369 )
370 })?;
371 cu29_units::constant::Unit::from_name(definition.coherent_unit)
372 .map(Some)
373 .ok_or_else(|| {
374 format!(
375 "Constant '{}' quantity '{}' has invalid coherent unit metadata '{}'",
376 self.id,
377 quantity.name(),
378 definition.coherent_unit
379 )
380 })
381 }
382
383 pub fn numbers(&self) -> Result<(bool, Vec<ConstantNumber>), String> {
384 fn number(value: &RonValue) -> Result<ConstantNumber, String> {
385 match value {
386 RonValue::Number(number) => match number {
387 Number::I8(value) => Ok(ConstantNumber::Signed(i64::from(*value))),
388 Number::I16(value) => Ok(ConstantNumber::Signed(i64::from(*value))),
389 Number::I32(value) => Ok(ConstantNumber::Signed(i64::from(*value))),
390 Number::I64(value) => Ok(ConstantNumber::Signed(*value)),
391 Number::U8(value) => Ok(ConstantNumber::Unsigned(u64::from(*value))),
392 Number::U16(value) => Ok(ConstantNumber::Unsigned(u64::from(*value))),
393 Number::U32(value) => Ok(ConstantNumber::Unsigned(u64::from(*value))),
394 Number::U64(value) => Ok(ConstantNumber::Unsigned(*value)),
395 Number::F32(value) => Ok(ConstantNumber::Float(f64::from(value.0))),
396 Number::F64(value) => Ok(ConstantNumber::Float(value.0)),
397 _ => Err("unsupported numeric representation".to_string()),
398 },
399 _ => Err("expected a number".to_string()),
400 }
401 }
402
403 let value = self
404 .value
405 .as_ref()
406 .ok_or_else(|| format!("Constant '{}' does not declare a numeric value", self.id))?;
407 match &value.0 {
408 RonValue::Seq(values) => values
409 .iter()
410 .map(number)
411 .collect::<Result<Vec<_>, _>>()
412 .map(|values| (true, values)),
413 value => number(value).map(|value| (false, vec![value])),
414 }
415 .map_err(|error| format!("Constant '{}': {error}", self.id))
416 }
417
418 pub fn normalized_f32(&self) -> Result<(bool, Vec<f32>), String> {
419 let quantity = self.quantity.ok_or_else(|| {
420 format!(
421 "Constant '{}' does not declare a physical quantity",
422 self.id
423 )
424 })?;
425 let unit = self
426 .resolved_unit()?
427 .ok_or_else(|| format!("Constant '{}' has no resolved unit", self.id))?;
428 let (is_array, numbers) = self.numbers()?;
429 numbers
430 .into_iter()
431 .map(|number| {
432 let value = number.as_f64() as f32;
433 if !value.is_finite() {
434 return Err(format!("Constant '{}' values must be finite", self.id));
435 }
436 cu29_units::constant::normalize_f32(quantity, unit, value).ok_or_else(|| {
437 format!(
438 "Constant '{}' unit '{}' is not compatible with quantity '{}'",
439 self.id,
440 unit.name(),
441 quantity.name()
442 )
443 })
444 })
445 .collect::<Result<Vec<_>, _>>()
446 .map(|values| (is_array, values))
447 }
448
449 pub fn normalized_f64(&self) -> Result<(bool, Vec<f64>), String> {
450 let quantity = self.quantity.ok_or_else(|| {
451 format!(
452 "Constant '{}' does not declare a physical quantity",
453 self.id
454 )
455 })?;
456 let unit = self
457 .resolved_unit()?
458 .ok_or_else(|| format!("Constant '{}' has no resolved unit", self.id))?;
459 let (is_array, numbers) = self.numbers()?;
460 numbers
461 .into_iter()
462 .map(|number| {
463 let value = number.as_f64();
464 if !value.is_finite() {
465 return Err(format!("Constant '{}' values must be finite", self.id));
466 }
467 cu29_units::constant::normalize_f64(quantity, unit, value).ok_or_else(|| {
468 format!(
469 "Constant '{}' unit '{}' is not compatible with quantity '{}'",
470 self.id,
471 unit.name(),
472 quantity.name()
473 )
474 })
475 })
476 .collect::<Result<Vec<_>, _>>()
477 .map(|values| (is_array, values))
478 }
479
480 #[allow(dead_code)]
482 pub fn semantic_fingerprint(&self) -> Result<u64, String> {
483 const OFFSET: u64 = 0xcbf2_9ce4_8422_2325;
484 const PRIME: u64 = 0x0000_0100_0000_01b3;
485
486 fn hash_bytes(hash: &mut u64, bytes: &[u8]) {
487 for byte in bytes {
488 *hash ^= u64::from(*byte);
489 *hash = hash.wrapping_mul(PRIME);
490 }
491 }
492
493 let mut hash = OFFSET;
494 if let Some((rust_type, expression)) = self.expression_definition() {
495 hash_bytes(&mut hash, b"expression");
496 hash_bytes(&mut hash, rust_type.as_bytes());
497 hash_bytes(&mut hash, &[0]);
498 hash_bytes(&mut hash, expression.as_bytes());
499 return Ok(hash);
500 }
501
502 let storage = self.storage();
503 hash_bytes(&mut hash, b"numeric");
504 hash_bytes(&mut hash, storage.rust_type().as_bytes());
505 hash_bytes(
506 &mut hash,
507 self.quantity
508 .map_or("primitive", |quantity| quantity.name())
509 .as_bytes(),
510 );
511
512 if self.quantity.is_some() {
513 match storage {
514 ConstantStorage::F32 => {
515 let (is_array, values) = self.normalized_f32()?;
516 hash_bytes(&mut hash, &[u8::from(is_array)]);
517 for value in values {
518 hash_bytes(&mut hash, &value.to_bits().to_le_bytes());
519 }
520 }
521 ConstantStorage::F64 => {
522 let (is_array, values) = self.normalized_f64()?;
523 hash_bytes(&mut hash, &[u8::from(is_array)]);
524 for value in values {
525 hash_bytes(&mut hash, &value.to_bits().to_le_bytes());
526 }
527 }
528 _ => {
529 return Err(format!(
530 "Constant '{}' quantity storage must be f32 or f64",
531 self.id
532 ));
533 }
534 }
535 return Ok(hash);
536 }
537
538 let (is_array, numbers) = self.numbers()?;
539 hash_bytes(&mut hash, &[u8::from(is_array)]);
540 for number in numbers {
541 match (storage, number) {
542 (ConstantStorage::F32, number) => {
543 hash_bytes(&mut hash, &(number.as_f64() as f32).to_bits().to_le_bytes())
544 }
545 (ConstantStorage::F64, number) => {
546 hash_bytes(&mut hash, &number.as_f64().to_bits().to_le_bytes())
547 }
548 (_, ConstantNumber::Signed(value)) => hash_bytes(&mut hash, &value.to_le_bytes()),
549 (_, ConstantNumber::Unsigned(value)) => hash_bytes(&mut hash, &value.to_le_bytes()),
550 (_, ConstantNumber::Float(value)) => {
551 hash_bytes(&mut hash, &value.to_bits().to_le_bytes())
552 }
553 }
554 }
555 Ok(hash)
556 }
557}
558
559#[derive(Debug, Clone, PartialEq)]
560pub struct ConfigError {
561 message: String,
562}
563
564impl ConfigError {
565 fn type_mismatch(expected: &'static str, value: &Value) -> Self {
566 ConfigError {
567 message: format!("Expected {expected} but got {value:?}"),
568 }
569 }
570
571 fn with_key(self, key: &str) -> Self {
572 ConfigError {
573 message: format!("Config key '{key}': {}", self.message),
574 }
575 }
576}
577
578impl Display for ConfigError {
579 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
580 write!(f, "{}", self.message)
581 }
582}
583
584#[cfg(feature = "std")]
585impl std::error::Error for ConfigError {}
586
587#[cfg(not(feature = "std"))]
588impl core::error::Error for ConfigError {}
589
590impl From<ConfigError> for CuError {
591 fn from(err: ConfigError) -> Self {
592 CuError::from(err.to_string())
593 }
594}
595
596macro_rules! impl_from_numeric_for_value {
598 ($($source:ty),* $(,)?) => {
599 $(impl From<$source> for Value {
600 fn from(value: $source) -> Self {
601 Value(RonValue::Number(value.into()))
602 }
603 })*
604 };
605}
606
607impl_from_numeric_for_value!(i8, i16, i32, i64, u8, u16, u32, u64, f32, f64);
609
610impl TryFrom<&Value> for bool {
611 type Error = ConfigError;
612
613 fn try_from(value: &Value) -> Result<Self, Self::Error> {
614 if let Value(RonValue::Bool(v)) = value {
615 Ok(*v)
616 } else {
617 Err(ConfigError::type_mismatch("bool", value))
618 }
619 }
620}
621
622impl From<Value> for bool {
623 fn from(value: Value) -> Self {
624 if let Value(RonValue::Bool(v)) = value {
625 v
626 } else {
627 panic!("Expected a Boolean variant but got {value:?}")
628 }
629 }
630}
631macro_rules! impl_from_value_for_int {
632 ($($target:ty),* $(,)?) => {
633 $(
634 impl From<Value> for $target {
635 fn from(value: Value) -> Self {
636 if let Value(RonValue::Number(num)) = value {
637 match num {
638 Number::I8(n) => n as $target,
639 Number::I16(n) => n as $target,
640 Number::I32(n) => n as $target,
641 Number::I64(n) => n as $target,
642 Number::U8(n) => n as $target,
643 Number::U16(n) => n as $target,
644 Number::U32(n) => n as $target,
645 Number::U64(n) => n as $target,
646 Number::F32(_) | Number::F64(_) => {
647 panic!("Expected an integer Number variant but got {num:?}")
648 }
649 _ => {
650 panic!("Expected an integer Number variant but got {num:?}")
651 }
652 }
653 } else {
654 panic!("Expected a Number variant but got {value:?}")
655 }
656 }
657 }
658 )*
659 };
660}
661
662impl_from_value_for_int!(u8, i8, u16, i16, u32, i32, u64, i64);
663
664macro_rules! impl_try_from_value_for_int {
665 ($($target:ty),* $(,)?) => {
666 $(
667 impl TryFrom<&Value> for $target {
668 type Error = ConfigError;
669
670 fn try_from(value: &Value) -> Result<Self, Self::Error> {
671 if let Value(RonValue::Number(num)) = value {
672 match num {
673 Number::I8(n) => Ok(*n as $target),
674 Number::I16(n) => Ok(*n as $target),
675 Number::I32(n) => Ok(*n as $target),
676 Number::I64(n) => Ok(*n as $target),
677 Number::U8(n) => Ok(*n as $target),
678 Number::U16(n) => Ok(*n as $target),
679 Number::U32(n) => Ok(*n as $target),
680 Number::U64(n) => Ok(*n as $target),
681 Number::F32(_) | Number::F64(_) => {
682 Err(ConfigError::type_mismatch("integer", value))
683 }
684 _ => {
685 Err(ConfigError::type_mismatch("integer", value))
686 }
687 }
688 } else {
689 Err(ConfigError::type_mismatch("integer", value))
690 }
691 }
692 }
693 )*
694 };
695}
696
697impl_try_from_value_for_int!(u8, i8, u16, i16, u32, i32, u64, i64);
698
699impl TryFrom<&Value> for f64 {
700 type Error = ConfigError;
701
702 fn try_from(value: &Value) -> Result<Self, Self::Error> {
703 if let Value(RonValue::Number(num)) = value {
704 let number = match num {
705 Number::I8(n) => *n as f64,
706 Number::I16(n) => *n as f64,
707 Number::I32(n) => *n as f64,
708 Number::I64(n) => *n as f64,
709 Number::U8(n) => *n as f64,
710 Number::U16(n) => *n as f64,
711 Number::U32(n) => *n as f64,
712 Number::U64(n) => *n as f64,
713 Number::F32(n) => n.0 as f64,
714 Number::F64(n) => n.0,
715 _ => {
716 return Err(ConfigError::type_mismatch("number", value));
717 }
718 };
719 Ok(number)
720 } else {
721 Err(ConfigError::type_mismatch("number", value))
722 }
723 }
724}
725
726impl From<Value> for f64 {
727 fn from(value: Value) -> Self {
728 if let Value(RonValue::Number(num)) = value {
729 num.into_f64()
730 } else {
731 panic!("Expected a Number variant but got {value:?}")
732 }
733 }
734}
735
736impl TryFrom<&Value> for f32 {
738 type Error = ConfigError;
739
740 fn try_from(value: &Value) -> Result<Self, Self::Error> {
741 if let Value(RonValue::Number(num)) = value {
742 let number = match num {
743 Number::I8(n) => *n as f32,
744 Number::I16(n) => *n as f32,
745 Number::I32(n) => *n as f32,
746 Number::I64(n) => *n as f32,
747 Number::U8(n) => *n as f32,
748 Number::U16(n) => *n as f32,
749 Number::U32(n) => *n as f32,
750 Number::U64(n) => *n as f32,
751 Number::F32(n) => n.0,
752 Number::F64(n) => n.0 as f32,
753 _ => {
754 return Err(ConfigError::type_mismatch("number", value));
755 }
756 };
757 Ok(number)
758 } else {
759 Err(ConfigError::type_mismatch("number", value))
760 }
761 }
762}
763
764impl From<Value> for f32 {
765 fn from(value: Value) -> Self {
766 if let Value(RonValue::Number(num)) = value {
767 num.into_f64() as f32
768 } else {
769 panic!("Expected a Number variant but got {value:?}")
770 }
771 }
772}
773
774impl From<String> for Value {
775 fn from(value: String) -> Self {
776 Value(RonValue::String(value))
777 }
778}
779
780impl TryFrom<&Value> for String {
781 type Error = ConfigError;
782
783 fn try_from(value: &Value) -> Result<Self, Self::Error> {
784 if let Value(RonValue::String(s)) = value {
785 Ok(s.clone())
786 } else {
787 Err(ConfigError::type_mismatch("string", value))
788 }
789 }
790}
791
792impl From<Value> for String {
793 fn from(value: Value) -> Self {
794 if let Value(RonValue::String(s)) = value {
795 s
796 } else {
797 panic!("Expected a String variant")
798 }
799 }
800}
801
802impl Display for Value {
803 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
804 let Value(value) = self;
805 match value {
806 RonValue::Number(n) => {
807 let s = match n {
808 Number::I8(n) => n.to_string(),
809 Number::I16(n) => n.to_string(),
810 Number::I32(n) => n.to_string(),
811 Number::I64(n) => n.to_string(),
812 Number::U8(n) => n.to_string(),
813 Number::U16(n) => n.to_string(),
814 Number::U32(n) => n.to_string(),
815 Number::U64(n) => n.to_string(),
816 Number::F32(n) => n.0.to_string(),
817 Number::F64(n) => n.0.to_string(),
818 _ => panic!("Expected a Number variant but got {value:?}"),
819 };
820 write!(f, "{s}")
821 }
822 RonValue::String(s) => write!(f, "{s}"),
823 RonValue::Bool(b) => write!(f, "{b}"),
824 RonValue::Map(m) => write!(f, "{m:?}"),
825 RonValue::Char(c) => write!(f, "{c:?}"),
826 RonValue::Unit => write!(f, "unit"),
827 RonValue::Option(o) => write!(f, "{o:?}"),
828 RonValue::Seq(s) => write!(f, "{s:?}"),
829 RonValue::Bytes(bytes) => write!(f, "{bytes:?}"),
830 }
831 }
832}
833
834#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, Default)]
844#[repr(u8)]
845pub enum HandleContent {
846 #[serde(rename = "all", alias = "All")]
848 #[default]
849 All = 0,
850 #[serde(rename = "touched_only", alias = "TouchedOnly")]
852 TouchedOnly = 1,
853 #[serde(rename = "none", alias = "None")]
855 None = 2,
856}
857
858impl HandleContent {
859 #[allow(dead_code)] pub fn from_u8(v: u8) -> Self {
863 match v {
864 1 => HandleContent::TouchedOnly,
865 2 => HandleContent::None,
866 _ => HandleContent::All,
867 }
868 }
869}
870
871#[derive(Serialize, Deserialize, Debug, Clone)]
873pub struct NodeLogging {
874 #[serde(default = "default_as_true")]
875 enabled: bool,
876 #[serde(skip_serializing_if = "Option::is_none")]
877 codec: Option<String>,
878 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
879 codecs: HashMap<String, String>,
880 #[serde(default, skip_serializing_if = "is_default_handle_content")]
883 handle_content: HandleContent,
884}
885
886fn is_default_handle_content(c: &HandleContent) -> bool {
887 *c == HandleContent::default()
888}
889
890impl NodeLogging {
891 #[allow(dead_code)]
892 pub fn enabled(&self) -> bool {
893 self.enabled
894 }
895
896 #[allow(dead_code)]
897 pub fn codec(&self) -> Option<&str> {
898 self.codec.as_deref()
899 }
900
901 #[allow(dead_code)]
902 pub fn codecs(&self) -> &HashMap<String, String> {
903 &self.codecs
904 }
905
906 #[allow(dead_code)]
907 pub fn codec_for_msg_type(&self, msg_type: &str) -> Option<&str> {
908 self.codecs
909 .get(msg_type)
910 .map(String::as_str)
911 .or(self.codec.as_deref())
912 }
913
914 pub fn handle_content(&self) -> HandleContent {
917 self.handle_content
918 }
919}
920
921impl Default for NodeLogging {
922 fn default() -> Self {
923 Self {
924 enabled: true,
925 codec: None,
926 codecs: HashMap::new(),
927 handle_content: HandleContent::default(),
928 }
929 }
930}
931
932#[derive(Default, Debug, Copy, Clone, PartialEq, Eq)]
935pub enum Flavor {
936 #[default]
937 Task,
938 Bridge,
939}
940
941#[derive(Serialize, Deserialize, Debug, Copy, Clone, PartialEq, Eq)]
946pub enum TaskKind {
947 #[serde(rename = "source", alias = "src")]
948 Source,
949 #[serde(rename = "task", alias = "regular", alias = "cutask")]
950 Regular,
951 #[serde(rename = "sink", alias = "snk")]
952 Sink,
953}
954
955impl TaskKind {
956 #[allow(dead_code)]
957 pub fn as_str(&self) -> &'static str {
958 match self {
959 TaskKind::Source => "source",
960 TaskKind::Regular => "task",
961 TaskKind::Sink => "sink",
962 }
963 }
964}
965
966pub const DEFAULT_BACKGROUND_POOL: &str = "background";
968
969#[allow(dead_code)] pub const RT_POOL: &str = "rt";
973
974#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
980#[serde(untagged)]
981pub enum BackgroundConfig {
982 Flag(bool),
984 Pool { pool: String },
986}
987
988#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Default)]
1002pub struct AnytimeConfig {
1003 #[serde(skip_serializing_if = "Option::is_none")]
1008 pub time_budget_ms: Option<f64>,
1009
1010 #[serde(skip_serializing_if = "Option::is_none")]
1014 pub max_age_ms: Option<f64>,
1015
1016 #[serde(skip_serializing_if = "Option::is_none")]
1020 pub quality_target: Option<f32>,
1021
1022 #[serde(skip_serializing_if = "Option::is_none")]
1026 pub quality_floor: Option<f32>,
1027
1028 #[serde(skip_serializing_if = "Option::is_none")]
1030 pub max_refines: Option<u32>,
1031
1032 #[serde(skip_serializing_if = "Option::is_none")]
1035 pub max_stall: Option<u32>,
1036}
1037
1038impl AnytimeConfig {
1039 fn validate(&self, task_id: &str) -> CuResult<()> {
1046 if let Some(budget) = self.time_budget_ms {
1047 let valid = budget.is_finite() && budget > 0.0;
1048 if !valid {
1049 return Err(CuError::from(format!(
1050 "Task '{task_id}': anytime.time_budget_ms must be a positive number of milliseconds (got {budget})."
1051 )));
1052 }
1053 }
1054 if let Some(age) = self.max_age_ms {
1055 let valid = age.is_finite() && age > 0.0;
1056 if !valid {
1057 return Err(CuError::from(format!(
1058 "Task '{task_id}': anytime.max_age_ms must be a positive number of milliseconds (got {age})."
1059 )));
1060 }
1061 }
1062 if let Some(target) = self.quality_target {
1063 let valid = target > 0.0 && target <= 1.0;
1064 if !valid {
1065 return Err(CuError::from(format!(
1066 "Task '{task_id}': anytime.quality_target must be within (0.0, 1.0] (got {target})."
1067 )));
1068 }
1069 }
1070 if let Some(floor) = self.quality_floor {
1071 let valid = floor > 0.0 && floor < 1.0;
1072 if !valid {
1073 return Err(CuError::from(format!(
1074 "Task '{task_id}': anytime.quality_floor must be within (0.0, 1.0) (got {floor})."
1075 )));
1076 }
1077 }
1078 if let Some(refines) = self.max_refines
1079 && refines == 0
1080 {
1081 return Err(CuError::from(format!(
1082 "Task '{task_id}': anytime.max_refines must be at least 1."
1083 )));
1084 }
1085 if let Some(stall) = self.max_stall
1086 && stall == 0
1087 {
1088 return Err(CuError::from(format!(
1089 "Task '{task_id}': anytime.max_stall must be at least 1."
1090 )));
1091 }
1092 if let (Some(floor), Some(target)) = (self.quality_floor, self.quality_target)
1093 && floor > target
1094 {
1095 return Err(CuError::from(format!(
1096 "Task '{task_id}': anytime.quality_floor ({floor}) must not exceed anytime.quality_target ({target}): refinement could stop at the target and then always discard the result."
1097 )));
1098 }
1099 if self.time_budget_ms.is_none() && self.max_age_ms.is_none() && self.max_refines.is_none()
1100 {
1101 return Err(CuError::from(format!(
1102 "Task '{task_id}': anytime needs at least one hard bound: set time_budget_ms, max_age_ms or max_refines. quality_target, max_stall and quality_floor alone leave refinement unbounded."
1103 )));
1104 }
1105 Ok(())
1106 }
1107}
1108
1109#[derive(Serialize, Deserialize, Debug, Clone)]
1112pub struct Node {
1113 id: String,
1115
1116 #[serde(rename = "type", skip_serializing_if = "Option::is_none")]
1118 type_: Option<String>,
1119
1120 #[serde(skip_serializing_if = "Option::is_none")]
1123 kind: Option<TaskKind>,
1124
1125 #[serde(skip_serializing_if = "Option::is_none")]
1127 config: Option<ComponentConfig>,
1128
1129 #[serde(skip_serializing_if = "Option::is_none")]
1131 resources: Option<HashMap<String, String>>,
1132
1133 missions: Option<Vec<String>>,
1135
1136 #[serde(skip_serializing_if = "Option::is_none")]
1143 background: Option<BackgroundConfig>,
1144
1145 #[serde(skip_serializing_if = "Option::is_none")]
1150 anytime: Option<AnytimeConfig>,
1151
1152 #[serde(skip_serializing_if = "Option::is_none")]
1158 run_in_sim: Option<bool>,
1159
1160 #[serde(skip_serializing_if = "Option::is_none")]
1162 logging: Option<NodeLogging>,
1163
1164 #[serde(skip, default)]
1166 flavor: Flavor,
1167 #[serde(skip, default)]
1169 nc_outputs: Vec<String>,
1170 #[serde(skip, default)]
1172 nc_output_orders: Vec<usize>,
1173}
1174
1175impl Node {
1176 #[allow(dead_code)]
1177 pub fn new(id: &str, ptype: &str) -> Self {
1178 Node {
1179 id: id.to_string(),
1180 type_: Some(ptype.to_string()),
1181 kind: None,
1182 config: None,
1183 resources: None,
1184 missions: None,
1185 background: None,
1186 anytime: None,
1187 run_in_sim: None,
1188 logging: None,
1189 flavor: Flavor::Task,
1190 nc_outputs: Vec::new(),
1191 nc_output_orders: Vec::new(),
1192 }
1193 }
1194
1195 #[allow(dead_code)]
1196 pub fn new_with_flavor(id: &str, ptype: &str, flavor: Flavor) -> Self {
1197 let mut node = Self::new(id, ptype);
1198 node.flavor = flavor;
1199 node
1200 }
1201
1202 #[allow(dead_code)]
1203 pub fn get_id(&self) -> String {
1204 self.id.clone()
1205 }
1206
1207 #[allow(dead_code)]
1208 pub fn get_type(&self) -> &str {
1209 self.type_.as_ref().unwrap()
1210 }
1211
1212 #[allow(dead_code)]
1213 pub fn set_type(mut self, name: Option<String>) -> Self {
1214 self.type_ = name;
1215 self
1216 }
1217
1218 #[allow(dead_code)]
1219 pub fn get_declared_task_kind(&self) -> Option<TaskKind> {
1220 self.kind
1221 }
1222
1223 #[allow(dead_code)]
1224 pub fn set_task_kind(&mut self, kind: Option<TaskKind>) {
1225 self.kind = kind;
1226 }
1227
1228 #[allow(dead_code)]
1229 pub fn set_resources<I>(&mut self, resources: Option<I>)
1230 where
1231 I: IntoIterator<Item = (String, String)>,
1232 {
1233 self.resources = resources.map(|iter| iter.into_iter().collect());
1234 }
1235
1236 #[allow(dead_code)]
1237 pub fn is_background(&self) -> bool {
1238 match &self.background {
1239 Some(BackgroundConfig::Flag(flag)) => *flag,
1240 Some(BackgroundConfig::Pool { .. }) => true,
1241 None => false,
1242 }
1243 }
1244
1245 #[allow(dead_code)]
1248 pub fn background_pool(&self) -> &str {
1249 match &self.background {
1250 Some(BackgroundConfig::Pool { pool }) => pool.as_str(),
1251 _ => DEFAULT_BACKGROUND_POOL,
1252 }
1253 }
1254
1255 #[allow(dead_code)]
1256 pub fn is_anytime(&self) -> bool {
1257 self.anytime.is_some()
1258 }
1259
1260 #[allow(dead_code)]
1262 pub fn anytime(&self) -> Option<&AnytimeConfig> {
1263 self.anytime.as_ref()
1264 }
1265
1266 #[allow(dead_code)]
1268 pub fn set_anytime(&mut self, anytime: Option<AnytimeConfig>) {
1269 self.anytime = anytime;
1270 }
1271
1272 #[allow(dead_code)]
1273 pub fn get_instance_config(&self) -> Option<&ComponentConfig> {
1274 self.config.as_ref()
1275 }
1276
1277 #[allow(dead_code)]
1278 pub fn get_resources(&self) -> Option<&HashMap<String, String>> {
1279 self.resources.as_ref()
1280 }
1281
1282 #[allow(dead_code)]
1285 pub fn is_run_in_sim(&self) -> bool {
1286 self.run_in_sim.unwrap_or(false)
1287 }
1288
1289 #[allow(dead_code)]
1290 pub fn is_logging_enabled(&self) -> bool {
1291 if let Some(logging) = &self.logging {
1292 logging.enabled()
1293 } else {
1294 true
1295 }
1296 }
1297
1298 #[allow(dead_code)]
1302 pub fn handle_content_policy(&self) -> HandleContent {
1303 self.logging
1304 .as_ref()
1305 .map(NodeLogging::handle_content)
1306 .unwrap_or_default()
1307 }
1308
1309 #[allow(dead_code)]
1310 pub fn get_logging(&self) -> Option<&NodeLogging> {
1311 self.logging.as_ref()
1312 }
1313
1314 #[allow(dead_code)]
1315 pub fn get_param<T>(&self, key: &str) -> Result<Option<T>, ConfigError>
1316 where
1317 T: for<'a> TryFrom<&'a Value, Error = ConfigError>,
1318 {
1319 let pc = match self.config.as_ref() {
1320 Some(pc) => pc,
1321 None => return Ok(None),
1322 };
1323 let ComponentConfig(pc) = pc;
1324 match pc.get(key) {
1325 Some(v) => T::try_from(v).map(Some),
1326 None => Ok(None),
1327 }
1328 }
1329
1330 #[allow(dead_code)]
1331 pub fn set_param<T: Into<Value>>(&mut self, key: &str, value: T) {
1332 if self.config.is_none() {
1333 self.config = Some(ComponentConfig(HashMap::new()));
1334 }
1335 let ComponentConfig(config) = self.config.as_mut().unwrap();
1336 config.insert(key.to_string(), value.into());
1337 }
1338
1339 #[allow(dead_code)]
1341 pub fn get_flavor(&self) -> Flavor {
1342 self.flavor
1343 }
1344
1345 #[allow(dead_code)]
1347 pub fn set_flavor(&mut self, flavor: Flavor) {
1348 self.flavor = flavor;
1349 }
1350
1351 #[allow(dead_code)]
1353 pub fn add_nc_output(&mut self, msg_type: &str, order: usize) {
1354 if let Some(pos) = self
1355 .nc_outputs
1356 .iter()
1357 .position(|existing| existing == msg_type)
1358 {
1359 if order < self.nc_output_orders[pos] {
1360 self.nc_output_orders[pos] = order;
1361 }
1362 return;
1363 }
1364 self.nc_outputs.push(msg_type.to_string());
1365 self.nc_output_orders.push(order);
1366 }
1367
1368 #[allow(dead_code)]
1370 pub fn nc_outputs(&self) -> &[String] {
1371 &self.nc_outputs
1372 }
1373
1374 #[allow(dead_code)]
1376 pub fn nc_outputs_with_order(&self) -> impl Iterator<Item = (&String, usize)> {
1377 self.nc_outputs
1378 .iter()
1379 .zip(self.nc_output_orders.iter().copied())
1380 }
1381}
1382
1383#[derive(Serialize, Deserialize, Debug, Clone)]
1385pub enum BridgeChannelConfigRepresentation {
1386 Rx {
1388 id: String,
1389 #[serde(skip_serializing_if = "Option::is_none")]
1391 route: Option<String>,
1392 #[serde(skip_serializing_if = "Option::is_none")]
1394 config: Option<ComponentConfig>,
1395 },
1396 Tx {
1398 id: String,
1399 #[serde(skip_serializing_if = "Option::is_none")]
1401 route: Option<String>,
1402 #[serde(skip_serializing_if = "Option::is_none")]
1404 config: Option<ComponentConfig>,
1405 },
1406}
1407
1408impl BridgeChannelConfigRepresentation {
1409 #[allow(dead_code)]
1411 pub fn id(&self) -> &str {
1412 match self {
1413 BridgeChannelConfigRepresentation::Rx { id, .. }
1414 | BridgeChannelConfigRepresentation::Tx { id, .. } => id,
1415 }
1416 }
1417
1418 #[allow(dead_code)]
1420 pub fn route(&self) -> Option<&str> {
1421 match self {
1422 BridgeChannelConfigRepresentation::Rx { route, .. }
1423 | BridgeChannelConfigRepresentation::Tx { route, .. } => route.as_deref(),
1424 }
1425 }
1426}
1427
1428enum EndpointRole {
1429 Source,
1430 Destination,
1431}
1432
1433fn validate_bridge_channel(
1434 bridge: &BridgeConfig,
1435 channel_id: &str,
1436 role: EndpointRole,
1437) -> Result<(), String> {
1438 let channel = bridge
1439 .channels
1440 .iter()
1441 .find(|ch| ch.id() == channel_id)
1442 .ok_or_else(|| {
1443 format!(
1444 "Bridge '{}' does not declare a channel named '{}'",
1445 bridge.id, channel_id
1446 )
1447 })?;
1448
1449 match (role, channel) {
1450 (EndpointRole::Source, BridgeChannelConfigRepresentation::Rx { .. }) => Ok(()),
1451 (EndpointRole::Destination, BridgeChannelConfigRepresentation::Tx { .. }) => Ok(()),
1452 (EndpointRole::Source, BridgeChannelConfigRepresentation::Tx { .. }) => Err(format!(
1453 "Bridge '{}' channel '{}' is Tx and cannot act as a source",
1454 bridge.id, channel_id
1455 )),
1456 (EndpointRole::Destination, BridgeChannelConfigRepresentation::Rx { .. }) => Err(format!(
1457 "Bridge '{}' channel '{}' is Rx and cannot act as a destination",
1458 bridge.id, channel_id
1459 )),
1460 }
1461}
1462
1463#[derive(Serialize, Deserialize, Debug, Clone)]
1465pub struct ResourceBundleConfig {
1466 pub id: String,
1467 #[serde(rename = "provider")]
1468 pub provider: String,
1469 #[serde(skip_serializing_if = "Option::is_none")]
1470 pub config: Option<ComponentConfig>,
1471 #[serde(skip_serializing_if = "Option::is_none")]
1472 pub missions: Option<Vec<String>>,
1473}
1474
1475#[derive(Serialize, Deserialize, Debug, Clone)]
1477pub struct BridgeConfig {
1478 pub id: String,
1479 #[serde(rename = "type")]
1480 pub type_: String,
1481 #[serde(skip_serializing_if = "Option::is_none")]
1482 pub config: Option<ComponentConfig>,
1483 #[serde(skip_serializing_if = "Option::is_none")]
1484 pub resources: Option<HashMap<String, String>>,
1485 #[serde(skip_serializing_if = "Option::is_none")]
1486 pub missions: Option<Vec<String>>,
1487 #[serde(skip_serializing_if = "Option::is_none")]
1492 pub run_in_sim: Option<bool>,
1493 pub channels: Vec<BridgeChannelConfigRepresentation>,
1495}
1496
1497impl BridgeConfig {
1498 #[allow(dead_code)]
1500 pub fn is_run_in_sim(&self) -> bool {
1501 self.run_in_sim.unwrap_or(true)
1502 }
1503
1504 fn to_node(&self) -> Node {
1505 let mut node = Node::new_with_flavor(&self.id, &self.type_, Flavor::Bridge);
1506 node.config = self.config.clone();
1507 node.resources = self.resources.clone();
1508 node.missions = self.missions.clone();
1509 node
1510 }
1511}
1512
1513fn insert_bridge_node(graph: &mut CuGraph, bridge: &BridgeConfig) -> Result<(), String> {
1514 if graph.get_node_id_by_name(bridge.id.as_str()).is_some() {
1515 return Err(format!(
1516 "Bridge '{}' reuses an existing node id. Bridge ids must be unique.",
1517 bridge.id
1518 ));
1519 }
1520 graph
1521 .add_node(bridge.to_node())
1522 .map(|_| ())
1523 .map_err(|e| e.to_string())
1524}
1525
1526#[derive(Serialize, Deserialize, Debug, Clone)]
1528struct SerializedCnx {
1529 src: String,
1530 dst: String,
1531 msg: String,
1532 missions: Option<Vec<String>>,
1533}
1534
1535pub const NC_ENDPOINT: &str = "__nc__";
1537
1538#[derive(Debug, Clone)]
1540pub struct Cnx {
1541 pub src: String,
1543 pub dst: String,
1545 pub msg: String,
1547 pub missions: Option<Vec<String>>,
1549 pub src_channel: Option<String>,
1551 pub dst_channel: Option<String>,
1553 pub order: usize,
1555}
1556
1557impl From<&Cnx> for SerializedCnx {
1558 fn from(cnx: &Cnx) -> Self {
1559 SerializedCnx {
1560 src: format_endpoint(&cnx.src, cnx.src_channel.as_deref()),
1561 dst: format_endpoint(&cnx.dst, cnx.dst_channel.as_deref()),
1562 msg: cnx.msg.clone(),
1563 missions: cnx.missions.clone(),
1564 }
1565 }
1566}
1567
1568fn format_endpoint(node: &str, channel: Option<&str>) -> String {
1569 match channel {
1570 Some(ch) => format!("{node}/{ch}"),
1571 None => node.to_string(),
1572 }
1573}
1574
1575fn parse_endpoint(
1576 endpoint: &str,
1577 role: EndpointRole,
1578 bridges: &HashMap<&str, &BridgeConfig>,
1579) -> Result<(String, Option<String>), String> {
1580 if let Some((node, channel)) = endpoint.split_once('/') {
1581 if let Some(bridge) = bridges.get(node) {
1582 validate_bridge_channel(bridge, channel, role)?;
1583 return Ok((node.to_string(), Some(channel.to_string())));
1584 } else {
1585 return Err(format!(
1586 "Endpoint '{endpoint}' references an unknown bridge '{node}'"
1587 ));
1588 }
1589 }
1590
1591 if let Some(bridge) = bridges.get(endpoint) {
1592 return Err(format!(
1593 "Bridge '{}' connections must reference a channel using '{}/<channel>'",
1594 bridge.id, bridge.id
1595 ));
1596 }
1597
1598 Ok((endpoint.to_string(), None))
1599}
1600
1601fn build_bridge_lookup(bridges: Option<&Vec<BridgeConfig>>) -> HashMap<&str, &BridgeConfig> {
1602 let mut map = HashMap::new();
1603 if let Some(bridges) = bridges {
1604 for bridge in bridges {
1605 map.insert(bridge.id.as_str(), bridge);
1606 }
1607 }
1608 map
1609}
1610
1611fn mission_applies(missions: &Option<Vec<String>>, mission_id: &str) -> bool {
1612 missions
1613 .as_ref()
1614 .map(|mission_list| mission_list.iter().any(|m| m == mission_id))
1615 .unwrap_or(true)
1616}
1617
1618fn merge_connection_missions(existing: &mut Option<Vec<String>>, incoming: &Option<Vec<String>>) {
1619 if incoming.is_none() {
1620 *existing = None;
1621 return;
1622 }
1623 if existing.is_none() {
1624 return;
1625 }
1626
1627 if let (Some(existing_missions), Some(incoming_missions)) =
1628 (existing.as_mut(), incoming.as_ref())
1629 {
1630 for mission in incoming_missions {
1631 if !existing_missions
1632 .iter()
1633 .any(|existing_mission| existing_mission == mission)
1634 {
1635 existing_missions.push(mission.clone());
1636 }
1637 }
1638 existing_missions.sort();
1639 existing_missions.dedup();
1640 }
1641}
1642
1643fn register_nc_output<E>(
1644 graph: &mut CuGraph,
1645 src_endpoint: &str,
1646 msg_type: &str,
1647 order: usize,
1648 bridge_lookup: &HashMap<&str, &BridgeConfig>,
1649) -> Result<(), E>
1650where
1651 E: From<String>,
1652{
1653 let (src_name, src_channel) =
1654 parse_endpoint(src_endpoint, EndpointRole::Source, bridge_lookup).map_err(E::from)?;
1655 if src_channel.is_some() {
1656 return Err(E::from(format!(
1657 "NC destination '{}' does not support bridge channels in source endpoint '{}'",
1658 NC_ENDPOINT, src_endpoint
1659 )));
1660 }
1661
1662 let src = graph
1663 .get_node_id_by_name(src_name.as_str())
1664 .ok_or_else(|| E::from(format!("Source node not found: {src_endpoint}")))?;
1665 let src_node = graph
1666 .get_node_mut(src)
1667 .ok_or_else(|| E::from(format!("Source node id {src} not found for NC output")))?;
1668 if src_node.get_flavor() != Flavor::Task {
1669 return Err(E::from(format!(
1670 "NC destination '{}' is only supported for task outputs (source '{}')",
1671 NC_ENDPOINT, src_endpoint
1672 )));
1673 }
1674 src_node.add_nc_output(msg_type, order);
1675 Ok(())
1676}
1677
1678#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1681pub enum CuDirection {
1682 Outgoing,
1683 Incoming,
1684}
1685
1686impl From<CuDirection> for petgraph::Direction {
1687 fn from(dir: CuDirection) -> Self {
1688 match dir {
1689 CuDirection::Outgoing => petgraph::Direction::Outgoing,
1690 CuDirection::Incoming => petgraph::Direction::Incoming,
1691 }
1692 }
1693}
1694
1695#[derive(Default, Debug, Clone)]
1696pub struct CuGraph(pub StableDiGraph<Node, Cnx, NodeId>);
1697
1698impl CuGraph {
1699 #[allow(dead_code)]
1700 pub fn get_all_nodes(&self) -> Vec<(NodeId, &Node)> {
1701 self.0
1702 .node_indices()
1703 .map(|index| (index.index() as u32, &self.0[index]))
1704 .collect()
1705 }
1706
1707 #[allow(dead_code)]
1708 pub fn get_neighbor_ids(&self, node_id: NodeId, dir: CuDirection) -> Vec<NodeId> {
1709 self.0
1710 .neighbors_directed(node_id.into(), dir.into())
1711 .map(|petgraph_index| petgraph_index.index() as NodeId)
1712 .collect()
1713 }
1714
1715 #[allow(dead_code)]
1716 pub fn node_ids(&self) -> Vec<NodeId> {
1717 self.0
1718 .node_indices()
1719 .map(|index| index.index() as NodeId)
1720 .collect()
1721 }
1722
1723 #[allow(dead_code)]
1724 pub fn edge_id_between(&self, source: NodeId, target: NodeId) -> Option<usize> {
1725 self.0
1726 .find_edge(source.into(), target.into())
1727 .map(|edge| edge.index())
1728 }
1729
1730 #[allow(dead_code)]
1731 pub fn edge(&self, edge_id: usize) -> Option<&Cnx> {
1732 self.0.edge_weight(EdgeIndex::new(edge_id))
1733 }
1734
1735 #[allow(dead_code)]
1736 pub fn edges(&self) -> impl Iterator<Item = &Cnx> {
1737 self.0
1738 .edge_indices()
1739 .filter_map(|edge| self.0.edge_weight(edge))
1740 }
1741
1742 #[allow(dead_code)]
1743 pub fn bfs_nodes(&self, start: NodeId) -> Vec<NodeId> {
1744 let mut visitor = Bfs::new(&self.0, start.into());
1745 let mut nodes = Vec::new();
1746 while let Some(node) = visitor.next(&self.0) {
1747 nodes.push(node.index() as NodeId);
1748 }
1749 nodes
1750 }
1751
1752 #[allow(dead_code)]
1753 pub fn incoming_neighbor_count(&self, node_id: NodeId) -> usize {
1754 self.0.neighbors_directed(node_id.into(), Incoming).count()
1755 }
1756
1757 #[allow(dead_code)]
1758 pub fn outgoing_neighbor_count(&self, node_id: NodeId) -> usize {
1759 self.0.neighbors_directed(node_id.into(), Outgoing).count()
1760 }
1761
1762 pub fn node_indices(&self) -> Vec<petgraph::stable_graph::NodeIndex> {
1763 self.0.node_indices().collect()
1764 }
1765
1766 pub fn add_node(&mut self, node: Node) -> CuResult<NodeId> {
1767 Ok(self.0.add_node(node).index() as NodeId)
1768 }
1769
1770 #[allow(dead_code)]
1771 pub fn connection_exists(&self, source: NodeId, target: NodeId) -> bool {
1772 self.0.find_edge(source.into(), target.into()).is_some()
1773 }
1774
1775 pub fn connect_ext(
1776 &mut self,
1777 source: NodeId,
1778 target: NodeId,
1779 msg_type: &str,
1780 missions: Option<Vec<String>>,
1781 src_channel: Option<String>,
1782 dst_channel: Option<String>,
1783 ) -> CuResult<()> {
1784 self.connect_ext_with_order(
1785 source,
1786 target,
1787 msg_type,
1788 missions,
1789 src_channel,
1790 dst_channel,
1791 usize::MAX,
1792 )
1793 }
1794
1795 #[allow(clippy::too_many_arguments)]
1796 pub fn connect_ext_with_order(
1797 &mut self,
1798 source: NodeId,
1799 target: NodeId,
1800 msg_type: &str,
1801 missions: Option<Vec<String>>,
1802 src_channel: Option<String>,
1803 dst_channel: Option<String>,
1804 order: usize,
1805 ) -> CuResult<()> {
1806 let (src_id, dst_id) = (
1807 self.0
1808 .node_weight(source.into())
1809 .ok_or("Source node not found")?
1810 .id
1811 .clone(),
1812 self.0
1813 .node_weight(target.into())
1814 .ok_or("Target node not found")?
1815 .id
1816 .clone(),
1817 );
1818
1819 let _ = self.0.add_edge(
1820 petgraph::stable_graph::NodeIndex::from(source),
1821 petgraph::stable_graph::NodeIndex::from(target),
1822 Cnx {
1823 src: src_id,
1824 dst: dst_id,
1825 msg: msg_type.to_string(),
1826 missions,
1827 src_channel,
1828 dst_channel,
1829 order,
1830 },
1831 );
1832 Ok(())
1833 }
1834 #[allow(dead_code)]
1838 pub fn get_node(&self, node_id: NodeId) -> Option<&Node> {
1839 self.0.node_weight(node_id.into())
1840 }
1841
1842 #[allow(dead_code)]
1843 pub fn get_node_weight(&self, index: NodeId) -> Option<&Node> {
1844 self.0.node_weight(index.into())
1845 }
1846
1847 #[allow(dead_code)]
1848 pub fn get_node_mut(&mut self, node_id: NodeId) -> Option<&mut Node> {
1849 self.0.node_weight_mut(node_id.into())
1850 }
1851
1852 pub fn get_node_id_by_name(&self, name: &str) -> Option<NodeId> {
1853 self.0
1854 .node_indices()
1855 .into_iter()
1856 .find(|idx| self.0[*idx].get_id() == name)
1857 .map(|i| i.index() as NodeId)
1858 }
1859
1860 #[allow(dead_code)]
1861 pub fn get_edge_weight(&self, index: usize) -> Option<Cnx> {
1862 self.0.edge_weight(EdgeIndex::new(index)).cloned()
1863 }
1864
1865 #[allow(dead_code)]
1866 pub fn get_node_output_msg_type(&self, node_id: &str) -> Option<String> {
1867 self.get_node_output_msg_types(node_id)
1868 .and_then(|mut msgs| msgs.drain(..1).next())
1869 }
1870
1871 #[allow(dead_code)]
1872 pub fn get_node_output_msg_types(&self, node_id: &str) -> Option<Vec<String>> {
1873 let node_id = self.get_node_id_by_name(node_id)?;
1874 let msgs = self.get_node_output_msg_types_by_id(node_id).ok()?;
1875 (!msgs.is_empty()).then_some(msgs)
1876 }
1877
1878 #[allow(dead_code)]
1879 pub fn get_node_output_msg_types_by_id(&self, node_id: NodeId) -> CuResult<Vec<String>> {
1880 let mut edge_ids = self.get_src_edges(node_id)?;
1881 edge_ids.sort();
1882
1883 let node = self
1884 .get_node(node_id)
1885 .ok_or_else(|| CuError::from(format!("Node id {node_id} not found")))?;
1886
1887 let mut msg_order: Vec<(usize, String)> = Vec::new();
1888 let mut record_msg = |msg: String, order: usize| {
1889 if let Some((existing_order, _)) = msg_order
1890 .iter_mut()
1891 .find(|(_, existing_msg)| *existing_msg == msg)
1892 {
1893 if order < *existing_order {
1894 *existing_order = order;
1895 }
1896 return;
1897 }
1898 msg_order.push((order, msg));
1899 };
1900
1901 for edge_id in edge_ids {
1902 let Some(edge) = self.edge(edge_id) else {
1903 continue;
1904 };
1905 let order = if edge.order == usize::MAX {
1906 edge_id
1907 } else {
1908 edge.order
1909 };
1910 record_msg(edge.msg.clone(), order);
1911 }
1912
1913 for (msg, order) in node.nc_outputs_with_order() {
1914 record_msg(msg.clone(), order);
1915 }
1916
1917 msg_order.sort_by(|(order_a, msg_a), (order_b, msg_b)| {
1918 order_a.cmp(order_b).then_with(|| msg_a.cmp(msg_b))
1919 });
1920 Ok(msg_order.into_iter().map(|(_, msg)| msg).collect())
1921 }
1922
1923 #[allow(dead_code)]
1924 pub fn get_node_input_msg_type(&self, node_id: &str) -> Option<String> {
1925 self.get_node_input_msg_types(node_id)
1926 .and_then(|mut v| v.pop())
1927 }
1928
1929 pub fn get_node_input_msg_types(&self, node_id: &str) -> Option<Vec<String>> {
1930 self.0.node_indices().find_map(|node_index| {
1931 if let Some(node) = self.0.node_weight(node_index) {
1932 if node.id != node_id {
1933 return None;
1934 }
1935 let edges: Vec<_> = self
1936 .0
1937 .edges_directed(node_index, Incoming)
1938 .map(|edge| edge.id().index())
1939 .collect();
1940 if edges.is_empty() {
1941 return None;
1942 }
1943 let mut edges = edges;
1944 edges.sort();
1945 let msgs = edges
1946 .into_iter()
1947 .map(|edge_id| {
1948 let cnx = self
1949 .0
1950 .edge_weight(EdgeIndex::new(edge_id))
1951 .expect("Found an cnx id but could not retrieve it back");
1952 cnx.msg.clone()
1953 })
1954 .collect();
1955 return Some(msgs);
1956 }
1957 None
1958 })
1959 }
1960
1961 #[allow(dead_code)]
1962 pub fn get_connection_msg_type(&self, source: NodeId, target: NodeId) -> Option<&str> {
1963 self.0
1964 .find_edge(source.into(), target.into())
1965 .map(|edge_index| self.0[edge_index].msg.as_str())
1966 }
1967
1968 fn get_edges_by_direction(
1970 &self,
1971 node_id: NodeId,
1972 direction: petgraph::Direction,
1973 ) -> CuResult<Vec<usize>> {
1974 Ok(self
1975 .0
1976 .edges_directed(node_id.into(), direction)
1977 .map(|edge| edge.id().index())
1978 .collect())
1979 }
1980
1981 pub fn get_src_edges(&self, node_id: NodeId) -> CuResult<Vec<usize>> {
1982 self.get_edges_by_direction(node_id, Outgoing)
1983 }
1984
1985 pub fn get_dst_edges(&self, node_id: NodeId) -> CuResult<Vec<usize>> {
1987 self.get_edges_by_direction(node_id, Incoming)
1988 }
1989
1990 #[allow(dead_code)]
1991 pub fn node_count(&self) -> usize {
1992 self.0.node_count()
1993 }
1994
1995 #[allow(dead_code)]
1996 pub fn edge_count(&self) -> usize {
1997 self.0.edge_count()
1998 }
1999
2000 #[allow(dead_code)]
2003 pub fn connect(&mut self, source: NodeId, target: NodeId, msg_type: &str) -> CuResult<()> {
2004 self.connect_ext(source, target, msg_type, None, None, None)
2005 }
2006}
2007
2008fn validate_task_kind(
2009 node_id: &str,
2010 kind: TaskKind,
2011 has_inputs: bool,
2012 has_outputs: bool,
2013) -> CuResult<()> {
2014 match kind {
2015 TaskKind::Source if has_inputs => Err(CuError::from(format!(
2016 "Task '{node_id}' is declared as kind 'source' but has incoming connections. Sources map to CuSrcTask and cannot consume inputs. Use kind: task instead."
2017 ))),
2018 TaskKind::Regular if !has_inputs => Err(CuError::from(format!(
2019 "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."
2020 ))),
2021 TaskKind::Sink if has_outputs => Err(CuError::from(format!(
2022 "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."
2023 ))),
2024 TaskKind::Sink if !has_inputs => Err(CuError::from(format!(
2025 "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."
2026 ))),
2027 _ => Ok(()),
2028 }
2029}
2030
2031#[allow(dead_code)]
2032pub fn infer_task_kind_for_id(graph: &CuGraph, node_id: NodeId) -> Option<TaskKind> {
2033 let node = graph.get_node(node_id)?;
2034 if node.get_flavor() != Flavor::Task {
2035 return None;
2036 }
2037
2038 let has_inputs = !graph.get_dst_edges(node_id).ok()?.is_empty();
2039 let has_outputs = !graph
2040 .get_node_output_msg_types_by_id(node_id)
2041 .ok()?
2042 .is_empty();
2043
2044 match (has_inputs, has_outputs) {
2045 (false, true) => Some(TaskKind::Source),
2046 (true, true) => Some(TaskKind::Regular),
2047 (true, false) => Some(TaskKind::Sink),
2048 (false, false) => None,
2049 }
2050}
2051
2052#[allow(dead_code)]
2053pub fn resolve_task_kind_for_id(graph: &CuGraph, node_id: NodeId) -> CuResult<TaskKind> {
2054 let node = graph
2055 .get_node(node_id)
2056 .ok_or_else(|| CuError::from(format!("Task node id {node_id} not found")))?;
2057 if node.get_flavor() != Flavor::Task {
2058 return Err(CuError::from(format!(
2059 "Node '{}' is not a task and does not have a task kind.",
2060 node.id
2061 )));
2062 }
2063
2064 let has_inputs = !graph.get_dst_edges(node_id)?.is_empty();
2065 let has_outputs = !graph.get_node_output_msg_types_by_id(node_id)?.is_empty();
2066
2067 if let Some(kind) = node.get_declared_task_kind() {
2068 validate_task_kind(node.id.as_str(), kind, has_inputs, has_outputs)?;
2069 return Ok(kind);
2070 }
2071
2072 let inferred = match (has_inputs, has_outputs) {
2073 (false, true) => TaskKind::Source,
2074 (true, true) => TaskKind::Regular,
2075 (true, false) => TaskKind::Sink,
2076 (false, false) => {
2077 return Err(CuError::from(format!(
2078 "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}\"`.",
2079 node.id
2080 )));
2081 }
2082 };
2083
2084 validate_task_kind(node.id.as_str(), inferred, has_inputs, has_outputs)?;
2085 Ok(inferred)
2086}
2087
2088impl core::ops::Index<NodeIndex> for CuGraph {
2089 type Output = Node;
2090
2091 fn index(&self, index: NodeIndex) -> &Self::Output {
2092 &self.0[index]
2093 }
2094}
2095
2096#[derive(Debug, Clone)]
2097pub enum ConfigGraphs {
2098 Simple(CuGraph),
2099 Missions(HashMap<String, CuGraph>),
2100}
2101
2102impl ConfigGraphs {
2103 #[allow(dead_code)]
2106 pub fn get_all_missions_graphs(&self) -> HashMap<String, CuGraph> {
2107 match self {
2108 Simple(graph) => HashMap::from([(DEFAULT_MISSION_ID.to_string(), graph.clone())]),
2109 Missions(graphs) => graphs.clone(),
2110 }
2111 }
2112
2113 #[allow(dead_code)]
2114 pub fn get_default_mission_graph(&self) -> CuResult<&CuGraph> {
2115 match self {
2116 Simple(graph) => Ok(graph),
2117 Missions(graphs) => {
2118 if graphs.len() == 1 {
2119 Ok(graphs.values().next().unwrap())
2120 } else {
2121 Err("Cannot get default mission graph from mission config".into())
2122 }
2123 }
2124 }
2125 }
2126
2127 #[allow(dead_code)]
2128 pub fn get_graph(&self, mission_id: Option<&str>) -> CuResult<&CuGraph> {
2129 match self {
2130 Simple(graph) => match mission_id {
2131 None | Some(DEFAULT_MISSION_ID) => Ok(graph),
2132 Some(_) => Err("Cannot get mission graph from simple config".into()),
2133 },
2134 Missions(graphs) => {
2135 let id = mission_id
2136 .ok_or_else(|| "Mission ID required for mission configs".to_string())?;
2137 graphs
2138 .get(id)
2139 .ok_or_else(|| format!("Mission {id} not found").into())
2140 }
2141 }
2142 }
2143
2144 #[allow(dead_code)]
2145 pub fn get_graph_mut(&mut self, mission_id: Option<&str>) -> CuResult<&mut CuGraph> {
2146 match self {
2147 Simple(graph) => match mission_id {
2148 None => Ok(graph),
2149 Some(_) => Err("Cannot get mission graph from simple config".into()),
2150 },
2151 Missions(graphs) => {
2152 let id = mission_id
2153 .ok_or_else(|| "Mission ID required for mission configs".to_string())?;
2154 graphs
2155 .get_mut(id)
2156 .ok_or_else(|| format!("Mission {id} not found").into())
2157 }
2158 }
2159 }
2160
2161 pub fn add_mission(&mut self, mission_id: &str) -> CuResult<&mut CuGraph> {
2162 match self {
2163 Simple(_) => Err("Cannot add mission to simple config".into()),
2164 Missions(graphs) => match graphs.entry(mission_id.to_string()) {
2165 hashbrown::hash_map::Entry::Occupied(_) => {
2166 Err(format!("Mission {mission_id} already exists").into())
2167 }
2168 hashbrown::hash_map::Entry::Vacant(entry) => Ok(entry.insert(CuGraph::default())),
2169 },
2170 }
2171 }
2172}
2173
2174#[derive(Debug, Clone)]
2180pub struct CuConfig {
2181 #[doc(hidden)]
2183 pub constants: Vec<ConstantConfig>,
2184 pub monitors: Vec<MonitorConfig>,
2186 pub logging: Option<LoggingConfig>,
2188 pub runtime: Option<RuntimeConfig>,
2190 pub resources: Vec<ResourceBundleConfig>,
2192 pub bridges: Vec<BridgeConfig>,
2194 pub graphs: ConfigGraphs,
2196}
2197
2198impl CuConfig {
2199 #[cfg(feature = "std")]
2205 fn ensure_default_background_pool(&mut self) {
2206 if !self.has_background_tasks() {
2207 return;
2208 }
2209
2210 const DEFAULT_BACKGROUND_THREADS: usize = 2;
2211
2212 let runtime = self.runtime.get_or_insert_with(RuntimeConfig::default);
2213 if !runtime
2214 .thread_pools
2215 .iter()
2216 .any(|pool| pool.id == DEFAULT_BACKGROUND_POOL)
2217 {
2218 runtime.thread_pools.push(ThreadPoolConfig {
2219 id: DEFAULT_BACKGROUND_POOL.to_string(),
2220 threads: DEFAULT_BACKGROUND_THREADS,
2221 affinity: None,
2222 policy: SchedulingPolicy::Fair,
2223 on_error: OnError::Warn,
2224 });
2225 }
2226 }
2227
2228 #[cfg(feature = "std")]
2229 fn has_background_tasks(&self) -> bool {
2230 match &self.graphs {
2231 ConfigGraphs::Simple(graph) => graph
2232 .get_all_nodes()
2233 .iter()
2234 .any(|(_, node)| node.is_background()),
2235 ConfigGraphs::Missions(graphs) => graphs.values().any(|graph| {
2236 graph
2237 .get_all_nodes()
2238 .iter()
2239 .any(|(_, node)| node.is_background())
2240 }),
2241 }
2242 }
2243}
2244
2245#[derive(Serialize, Deserialize, Default, Debug, Clone)]
2246pub struct MonitorConfig {
2247 #[serde(rename = "type")]
2248 type_: String,
2249 #[serde(skip_serializing_if = "Option::is_none")]
2250 config: Option<ComponentConfig>,
2251}
2252
2253impl MonitorConfig {
2254 #[allow(dead_code)]
2255 pub fn get_type(&self) -> &str {
2256 &self.type_
2257 }
2258
2259 #[allow(dead_code)]
2260 pub fn get_config(&self) -> Option<&ComponentConfig> {
2261 self.config.as_ref()
2262 }
2263}
2264
2265fn default_as_true() -> bool {
2266 true
2267}
2268
2269pub const DEFAULT_KEYFRAME_INTERVAL: u32 = 100;
2270
2271fn default_keyframe_interval() -> Option<u32> {
2272 Some(DEFAULT_KEYFRAME_INTERVAL)
2273}
2274
2275#[derive(Serialize, Deserialize, Debug, Clone)]
2276pub struct LoggingConfig {
2277 #[serde(default = "default_as_true", skip_serializing_if = "Clone::clone")]
2279 pub enable_task_logging: bool,
2280
2281 #[serde(skip_serializing_if = "Option::is_none")]
2286 pub copperlist_count: Option<usize>,
2287
2288 #[serde(skip_serializing_if = "Option::is_none")]
2290 pub slab_size_mib: Option<u64>,
2291
2292 #[serde(skip_serializing_if = "Option::is_none")]
2294 pub section_size_mib: Option<u64>,
2295
2296 #[serde(
2298 default = "default_keyframe_interval",
2299 skip_serializing_if = "Option::is_none"
2300 )]
2301 pub keyframe_interval: Option<u32>,
2302
2303 #[serde(default, skip_serializing_if = "Vec::is_empty")]
2305 pub codecs: Vec<LoggingCodecSpec>,
2306}
2307
2308impl Default for LoggingConfig {
2309 fn default() -> Self {
2310 Self {
2311 enable_task_logging: true,
2312 copperlist_count: None,
2313 slab_size_mib: None,
2314 section_size_mib: None,
2315 keyframe_interval: default_keyframe_interval(),
2316 codecs: Vec::new(),
2317 }
2318 }
2319}
2320
2321#[derive(Serialize, Deserialize, Debug, Clone)]
2322pub struct LoggingCodecSpec {
2323 pub id: String,
2324 #[serde(rename = "type")]
2325 pub type_: String,
2326 #[serde(skip_serializing_if = "Option::is_none")]
2327 pub config: Option<ComponentConfig>,
2328}
2329
2330#[derive(Serialize, Deserialize, Default, Debug, Clone)]
2331pub struct RuntimeConfig {
2332 #[serde(skip_serializing_if = "Option::is_none")]
2338 pub rate_target_hz: Option<u64>,
2339
2340 #[serde(default, skip_serializing_if = "Vec::is_empty")]
2347 pub thread_pools: Vec<ThreadPoolConfig>,
2348}
2349
2350pub const MIN_RT_PRIORITY: u8 = 1;
2352pub const MAX_RT_PRIORITY: u8 = 99;
2354pub const MIN_NICE: i8 = -20;
2356pub const MAX_NICE: i8 = 19;
2358
2359#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, Default)]
2365pub enum SchedulingPolicy {
2366 #[default]
2371 Fair,
2372 Nice(i8),
2379 Fifo { priority: u8 },
2387 RoundRobin { priority: u8 },
2394}
2395
2396#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, Default)]
2399pub enum OnError {
2400 #[default]
2403 Warn,
2404 Strict,
2407}
2408
2409#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
2411pub struct ThreadPoolConfig {
2412 pub id: String,
2415 pub threads: usize,
2417 #[serde(default, skip_serializing_if = "Option::is_none")]
2421 pub affinity: Option<Vec<usize>>,
2422 #[serde(default)]
2424 pub policy: SchedulingPolicy,
2425 #[serde(default)]
2427 pub on_error: OnError,
2428}
2429
2430fn validate_thread_pools<E>(runtime: &Option<RuntimeConfig>) -> Result<(), E>
2436where
2437 E: From<String>,
2438{
2439 let Some(runtime) = runtime else {
2440 return Ok(());
2441 };
2442
2443 let mut seen: Vec<&str> = Vec::new();
2444 for pool in &runtime.thread_pools {
2445 if pool.id.is_empty() {
2446 return Err(E::from("Thread pool id cannot be empty".to_string()));
2447 }
2448 if seen.contains(&pool.id.as_str()) {
2449 return Err(E::from(format!("Duplicate thread pool id '{}'", pool.id)));
2450 }
2451 seen.push(pool.id.as_str());
2452
2453 if pool.threads == 0 {
2454 return Err(E::from(format!(
2455 "Thread pool '{}' must have at least 1 thread",
2456 pool.id
2457 )));
2458 }
2459
2460 match pool.policy {
2461 SchedulingPolicy::Fifo { priority } | SchedulingPolicy::RoundRobin { priority } => {
2462 if !(MIN_RT_PRIORITY..=MAX_RT_PRIORITY).contains(&priority) {
2463 return Err(E::from(format!(
2464 "Thread pool '{}' real-time priority {priority} is out of range ({MIN_RT_PRIORITY}..={MAX_RT_PRIORITY})",
2465 pool.id
2466 )));
2467 }
2468 }
2469 SchedulingPolicy::Nice(nice) => {
2470 if !(MIN_NICE..=MAX_NICE).contains(&nice) {
2471 return Err(E::from(format!(
2472 "Thread pool '{}' niceness {nice} is out of range ({MIN_NICE}..={MAX_NICE})",
2473 pool.id
2474 )));
2475 }
2476 }
2477 SchedulingPolicy::Fair => {}
2478 }
2479
2480 if let Some(affinity) = &pool.affinity
2481 && affinity.is_empty()
2482 {
2483 return Err(E::from(format!(
2484 "Thread pool '{}' has an empty affinity list; omit `affinity` for no pinning",
2485 pool.id
2486 )));
2487 }
2488 }
2489
2490 Ok(())
2491}
2492
2493pub const MAX_RATE_TARGET_HZ: u64 = 1_000_000_000;
2498
2499#[derive(Serialize, Deserialize, Debug, Clone)]
2501pub struct MissionsConfig {
2502 pub id: String,
2503}
2504
2505#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
2507pub enum ConfigPredicate {
2508 Feature(String),
2509 Not(Box<ConfigPredicate>),
2510 All(Vec<ConfigPredicate>),
2511 Any(Vec<ConfigPredicate>),
2512}
2513
2514#[cfg(feature = "std")]
2515impl ConfigPredicate {
2516 fn evaluate(&self, active_features: &[&str]) -> bool {
2517 match self {
2518 Self::Feature(feature) => active_features.contains(&feature.as_str()),
2519 Self::Not(predicate) => !predicate.evaluate(active_features),
2520 Self::All(predicates) => predicates
2521 .iter()
2522 .all(|predicate| predicate.evaluate(active_features)),
2523 Self::Any(predicates) => predicates
2524 .iter()
2525 .any(|predicate| predicate.evaluate(active_features)),
2526 }
2527 }
2528}
2529
2530#[derive(Serialize, Deserialize, Debug, Clone)]
2532pub struct IncludesConfig {
2533 pub path: String,
2534 #[serde(default)]
2535 pub params: HashMap<String, Value>,
2536 #[serde(default)]
2537 pub missions: Option<Vec<String>>,
2538 #[serde(default)]
2539 pub when: Option<ConfigPredicate>,
2540}
2541
2542#[cfg(feature = "std")]
2544#[allow(dead_code)]
2545#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
2546pub struct MultiCopperSubsystemConfig {
2547 pub id: String,
2548 pub config: String,
2549}
2550
2551#[cfg(feature = "std")]
2553#[allow(dead_code)]
2554#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
2555pub struct MultiCopperInterconnectConfig {
2556 pub from: String,
2557 pub to: String,
2558 pub msg: String,
2559 #[serde(default)]
2560 pub when: Option<ConfigPredicate>,
2561}
2562
2563#[cfg(feature = "std")]
2565#[allow(dead_code)]
2566#[derive(Serialize, Deserialize, Debug, Clone)]
2567pub struct InstanceConfigSetOperation {
2568 pub path: String,
2569 pub value: ComponentConfig,
2570}
2571
2572#[cfg(feature = "std")]
2574#[allow(dead_code)]
2575#[derive(Debug, Clone, PartialEq, Eq, Hash)]
2576pub struct MultiCopperEndpoint {
2577 pub subsystem_id: String,
2578 pub bridge_id: String,
2579 pub channel_id: String,
2580}
2581
2582#[cfg(feature = "std")]
2583impl Display for MultiCopperEndpoint {
2584 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
2585 write!(
2586 f,
2587 "{}/{}/{}",
2588 self.subsystem_id, self.bridge_id, self.channel_id
2589 )
2590 }
2591}
2592
2593#[cfg(feature = "std")]
2595#[allow(dead_code)]
2596#[derive(Debug, Clone)]
2597pub struct MultiCopperSubsystem {
2598 pub id: String,
2599 pub subsystem_code: u16,
2600 pub config_path: String,
2601 pub config: CuConfig,
2602}
2603
2604#[cfg(feature = "std")]
2606#[allow(dead_code)]
2607#[derive(Debug, Clone, PartialEq, Eq)]
2608pub struct MultiCopperInterconnect {
2609 pub from: MultiCopperEndpoint,
2610 pub to: MultiCopperEndpoint,
2611 pub msg: String,
2612 pub bridge_type: String,
2613}
2614
2615#[cfg(feature = "std")]
2617#[allow(dead_code)]
2618#[derive(Debug, Clone)]
2619pub struct MultiCopperConfig {
2620 pub subsystems: Vec<MultiCopperSubsystem>,
2621 pub interconnects: Vec<MultiCopperInterconnect>,
2622 pub instance_overrides_root: Option<String>,
2623}
2624
2625#[cfg(feature = "std")]
2626impl MultiCopperConfig {
2627 #[allow(dead_code)]
2628 pub fn subsystem(&self, id: &str) -> Option<&MultiCopperSubsystem> {
2629 self.subsystems.iter().find(|subsystem| subsystem.id == id)
2630 }
2631
2632 #[allow(dead_code)]
2633 pub fn resolve_subsystem_config_for_instance(
2634 &self,
2635 subsystem_id: &str,
2636 instance_id: u32,
2637 ) -> CuResult<CuConfig> {
2638 let subsystem = self.subsystem(subsystem_id).ok_or_else(|| {
2639 CuError::from(format!(
2640 "Multi-Copper config does not define subsystem '{}'.",
2641 subsystem_id
2642 ))
2643 })?;
2644 let mut config = subsystem.config.clone();
2645
2646 let Some(root) = &self.instance_overrides_root else {
2647 return Ok(config);
2648 };
2649
2650 let override_path = std::path::Path::new(root)
2651 .join(instance_id.to_string())
2652 .join(format!("{subsystem_id}.ron"));
2653 if !override_path.exists() {
2654 return Ok(config);
2655 }
2656
2657 apply_instance_overrides_from_file(&mut config, &override_path)?;
2658 Ok(config)
2659 }
2660}
2661
2662#[cfg(feature = "std")]
2663#[allow(dead_code)]
2664#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
2665struct MultiCopperConfigRepresentation {
2666 subsystems: Vec<MultiCopperSubsystemConfig>,
2667 interconnects: Vec<MultiCopperInterconnectConfig>,
2668 instance_overrides_root: Option<String>,
2669}
2670
2671#[cfg(feature = "std")]
2672#[derive(Serialize, Deserialize, Debug, Clone, Default)]
2673struct InstanceConfigOverridesRepresentation {
2674 #[serde(default)]
2675 set: Vec<InstanceConfigSetOperation>,
2676}
2677
2678#[cfg(feature = "std")]
2679#[allow(dead_code)]
2680#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2681enum MultiCopperChannelDirection {
2682 Rx,
2683 Tx,
2684}
2685
2686#[cfg(feature = "std")]
2687#[allow(dead_code)]
2688#[derive(Debug, Clone)]
2689struct MultiCopperChannelContract {
2690 bridge_type: String,
2691 direction: MultiCopperChannelDirection,
2692 msg: Option<String>,
2693}
2694
2695#[cfg(feature = "std")]
2696#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2697enum InstanceConfigTargetKind {
2698 Task,
2699 Resource,
2700 Bridge,
2701}
2702
2703#[derive(Serialize, Deserialize, Default)]
2705struct CuConfigRepresentation {
2706 constants: Option<Vec<ConstantConfig>>,
2707 tasks: Option<Vec<Node>>,
2708 resources: Option<Vec<ResourceBundleConfig>>,
2709 bridges: Option<Vec<BridgeConfig>>,
2710 cnx: Option<Vec<SerializedCnx>>,
2711 #[serde(
2712 default,
2713 alias = "monitor",
2714 deserialize_with = "deserialize_monitor_configs"
2715 )]
2716 monitors: Option<Vec<MonitorConfig>>,
2717 logging: Option<LoggingConfig>,
2718 runtime: Option<RuntimeConfig>,
2719 missions: Option<Vec<MissionsConfig>>,
2720 includes: Option<Vec<IncludesConfig>>,
2721}
2722
2723#[derive(Deserialize)]
2724#[serde(untagged)]
2725enum OneOrManyMonitorConfig {
2726 One(MonitorConfig),
2727 Many(Vec<MonitorConfig>),
2728}
2729
2730fn deserialize_monitor_configs<'de, D>(
2731 deserializer: D,
2732) -> Result<Option<Vec<MonitorConfig>>, D::Error>
2733where
2734 D: Deserializer<'de>,
2735{
2736 let parsed = Option::<OneOrManyMonitorConfig>::deserialize(deserializer)?;
2737 Ok(parsed.map(|value| match value {
2738 OneOrManyMonitorConfig::One(single) => vec![single],
2739 OneOrManyMonitorConfig::Many(many) => many,
2740 }))
2741}
2742
2743fn deserialize_config_representation<E>(
2745 representation: &CuConfigRepresentation,
2746) -> Result<CuConfig, E>
2747where
2748 E: From<String>,
2749{
2750 let mut cuconfig = CuConfig::default();
2751 let bridge_lookup = build_bridge_lookup(representation.bridges.as_ref());
2752
2753 if let Some(mission_configs) = &representation.missions {
2754 let mut missions = Missions(HashMap::new());
2756
2757 for mission_config in mission_configs {
2758 let mission_id = mission_config.id.as_str();
2759 let graph = missions
2760 .add_mission(mission_id)
2761 .map_err(|e| E::from(e.to_string()))?;
2762
2763 if let Some(tasks) = &representation.tasks {
2764 for task in tasks {
2765 if let Some(task_missions) = &task.missions {
2766 if task_missions.contains(&mission_id.to_owned()) {
2768 graph
2769 .add_node(task.clone())
2770 .map_err(|e| E::from(e.to_string()))?;
2771 }
2772 } else {
2773 graph
2775 .add_node(task.clone())
2776 .map_err(|e| E::from(e.to_string()))?;
2777 }
2778 }
2779 }
2780
2781 if let Some(bridges) = &representation.bridges {
2782 for bridge in bridges {
2783 if mission_applies(&bridge.missions, mission_id) {
2784 insert_bridge_node(graph, bridge).map_err(E::from)?;
2785 }
2786 }
2787 }
2788
2789 if let Some(cnx) = &representation.cnx {
2790 for (connection_order, c) in cnx.iter().enumerate() {
2791 if let Some(cnx_missions) = &c.missions {
2792 if cnx_missions.contains(&mission_id.to_owned()) {
2794 if c.dst == NC_ENDPOINT {
2795 register_nc_output::<E>(
2796 graph,
2797 &c.src,
2798 &c.msg,
2799 connection_order,
2800 &bridge_lookup,
2801 )?;
2802 continue;
2803 }
2804 let (src_name, src_channel) =
2805 parse_endpoint(&c.src, EndpointRole::Source, &bridge_lookup)
2806 .map_err(E::from)?;
2807 let (dst_name, dst_channel) =
2808 parse_endpoint(&c.dst, EndpointRole::Destination, &bridge_lookup)
2809 .map_err(E::from)?;
2810 let src =
2811 graph
2812 .get_node_id_by_name(src_name.as_str())
2813 .ok_or_else(|| {
2814 E::from(format!("Source node not found: {}", c.src))
2815 })?;
2816 let dst =
2817 graph
2818 .get_node_id_by_name(dst_name.as_str())
2819 .ok_or_else(|| {
2820 E::from(format!("Destination node not found: {}", c.dst))
2821 })?;
2822 graph
2823 .connect_ext_with_order(
2824 src,
2825 dst,
2826 &c.msg,
2827 Some(cnx_missions.clone()),
2828 src_channel,
2829 dst_channel,
2830 connection_order,
2831 )
2832 .map_err(|e| E::from(e.to_string()))?;
2833 }
2834 } else {
2835 if c.dst == NC_ENDPOINT {
2837 register_nc_output::<E>(
2838 graph,
2839 &c.src,
2840 &c.msg,
2841 connection_order,
2842 &bridge_lookup,
2843 )?;
2844 continue;
2845 }
2846 let (src_name, src_channel) =
2847 parse_endpoint(&c.src, EndpointRole::Source, &bridge_lookup)
2848 .map_err(E::from)?;
2849 let (dst_name, dst_channel) =
2850 parse_endpoint(&c.dst, EndpointRole::Destination, &bridge_lookup)
2851 .map_err(E::from)?;
2852 let src = graph
2853 .get_node_id_by_name(src_name.as_str())
2854 .ok_or_else(|| E::from(format!("Source node not found: {}", c.src)))?;
2855 let dst =
2856 graph
2857 .get_node_id_by_name(dst_name.as_str())
2858 .ok_or_else(|| {
2859 E::from(format!("Destination node not found: {}", c.dst))
2860 })?;
2861 graph
2862 .connect_ext_with_order(
2863 src,
2864 dst,
2865 &c.msg,
2866 None,
2867 src_channel,
2868 dst_channel,
2869 connection_order,
2870 )
2871 .map_err(|e| E::from(e.to_string()))?;
2872 }
2873 }
2874 }
2875 }
2876 cuconfig.graphs = missions;
2877 } else {
2878 let mut graph = CuGraph::default();
2880
2881 if let Some(tasks) = &representation.tasks {
2882 for task in tasks {
2883 graph
2884 .add_node(task.clone())
2885 .map_err(|e| E::from(e.to_string()))?;
2886 }
2887 }
2888
2889 if let Some(bridges) = &representation.bridges {
2890 for bridge in bridges {
2891 insert_bridge_node(&mut graph, bridge).map_err(E::from)?;
2892 }
2893 }
2894
2895 if let Some(cnx) = &representation.cnx {
2896 for (connection_order, c) in cnx.iter().enumerate() {
2897 if c.dst == NC_ENDPOINT {
2898 register_nc_output::<E>(
2899 &mut graph,
2900 &c.src,
2901 &c.msg,
2902 connection_order,
2903 &bridge_lookup,
2904 )?;
2905 continue;
2906 }
2907 let (src_name, src_channel) =
2908 parse_endpoint(&c.src, EndpointRole::Source, &bridge_lookup)
2909 .map_err(E::from)?;
2910 let (dst_name, dst_channel) =
2911 parse_endpoint(&c.dst, EndpointRole::Destination, &bridge_lookup)
2912 .map_err(E::from)?;
2913 let src = graph
2914 .get_node_id_by_name(src_name.as_str())
2915 .ok_or_else(|| E::from(format!("Source node not found: {}", c.src)))?;
2916 let dst = graph
2917 .get_node_id_by_name(dst_name.as_str())
2918 .ok_or_else(|| E::from(format!("Destination node not found: {}", c.dst)))?;
2919 graph
2920 .connect_ext_with_order(
2921 src,
2922 dst,
2923 &c.msg,
2924 None,
2925 src_channel,
2926 dst_channel,
2927 connection_order,
2928 )
2929 .map_err(|e| E::from(e.to_string()))?;
2930 }
2931 }
2932 cuconfig.graphs = Simple(graph);
2933 }
2934
2935 cuconfig.monitors = representation.monitors.clone().unwrap_or_default();
2936 cuconfig.constants = representation.constants.clone().unwrap_or_default();
2937 cuconfig.logging = representation.logging.clone();
2938 cuconfig.runtime = representation.runtime.clone();
2939 cuconfig.resources = representation.resources.clone().unwrap_or_default();
2940 cuconfig.bridges = representation.bridges.clone().unwrap_or_default();
2941
2942 validate_thread_pools::<E>(&cuconfig.runtime)?;
2943
2944 Ok(cuconfig)
2945}
2946
2947impl<'de> Deserialize<'de> for CuConfig {
2948 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
2950 where
2951 D: Deserializer<'de>,
2952 {
2953 let representation =
2954 CuConfigRepresentation::deserialize(deserializer).map_err(serde::de::Error::custom)?;
2955
2956 match deserialize_config_representation::<String>(&representation) {
2958 Ok(config) => Ok(config),
2959 Err(e) => Err(serde::de::Error::custom(e)),
2960 }
2961 }
2962}
2963
2964impl Serialize for CuConfig {
2965 fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
2967 where
2968 S: Serializer,
2969 {
2970 let bridges = if self.bridges.is_empty() {
2971 None
2972 } else {
2973 Some(self.bridges.clone())
2974 };
2975 let resources = if self.resources.is_empty() {
2976 None
2977 } else {
2978 Some(self.resources.clone())
2979 };
2980 let monitors = (!self.monitors.is_empty()).then_some(self.monitors.clone());
2981 match &self.graphs {
2982 Simple(graph) => {
2983 let tasks: Vec<Node> = graph
2984 .0
2985 .node_indices()
2986 .map(|idx| graph.0[idx].clone())
2987 .filter(|node| node.get_flavor() == Flavor::Task)
2988 .collect();
2989
2990 let mut ordered_cnx: Vec<(usize, SerializedCnx)> = graph
2991 .0
2992 .edge_indices()
2993 .map(|edge_idx| {
2994 let edge = &graph.0[edge_idx];
2995 let order = if edge.order == usize::MAX {
2996 edge_idx.index()
2997 } else {
2998 edge.order
2999 };
3000 (order, SerializedCnx::from(edge))
3001 })
3002 .collect();
3003 for node_idx in graph.0.node_indices() {
3004 let node = &graph.0[node_idx];
3005 if node.get_flavor() != Flavor::Task {
3006 continue;
3007 }
3008 for (msg, order) in node.nc_outputs_with_order() {
3009 ordered_cnx.push((
3010 order,
3011 SerializedCnx {
3012 src: node.get_id(),
3013 dst: NC_ENDPOINT.to_string(),
3014 msg: msg.clone(),
3015 missions: None,
3016 },
3017 ));
3018 }
3019 }
3020 ordered_cnx.sort_by(|(order_a, cnx_a), (order_b, cnx_b)| {
3021 order_a
3022 .cmp(order_b)
3023 .then_with(|| cnx_a.src.cmp(&cnx_b.src))
3024 .then_with(|| cnx_a.dst.cmp(&cnx_b.dst))
3025 .then_with(|| cnx_a.msg.cmp(&cnx_b.msg))
3026 });
3027 let cnx: Vec<SerializedCnx> = ordered_cnx
3028 .into_iter()
3029 .map(|(_, serialized)| serialized)
3030 .collect();
3031
3032 CuConfigRepresentation {
3033 constants: (!self.constants.is_empty()).then_some(self.constants.clone()),
3034 tasks: Some(tasks),
3035 bridges: bridges.clone(),
3036 cnx: Some(cnx),
3037 monitors: monitors.clone(),
3038 logging: self.logging.clone(),
3039 runtime: self.runtime.clone(),
3040 resources: resources.clone(),
3041 missions: None,
3042 includes: None,
3043 }
3044 .serialize(serializer)
3045 }
3046 Missions(graphs) => {
3047 let missions = graphs
3048 .keys()
3049 .map(|id| MissionsConfig { id: id.clone() })
3050 .collect();
3051
3052 let mut tasks = Vec::new();
3054 let mut ordered_cnx: Vec<(usize, SerializedCnx)> = Vec::new();
3055
3056 for (mission_id, graph) in graphs {
3057 for node_idx in graph.node_indices() {
3059 let node = &graph[node_idx];
3060 if node.get_flavor() == Flavor::Task
3061 && !tasks.iter().any(|n: &Node| n.id == node.id)
3062 {
3063 tasks.push(node.clone());
3064 }
3065 }
3066
3067 for edge_idx in graph.0.edge_indices() {
3069 let edge = &graph.0[edge_idx];
3070 let order = if edge.order == usize::MAX {
3071 edge_idx.index()
3072 } else {
3073 edge.order
3074 };
3075 let serialized = SerializedCnx::from(edge);
3076 if let Some((existing_order, existing_serialized)) =
3077 ordered_cnx.iter_mut().find(|(_, c)| {
3078 c.src == serialized.src
3079 && c.dst == serialized.dst
3080 && c.msg == serialized.msg
3081 })
3082 {
3083 if order < *existing_order {
3084 *existing_order = order;
3085 }
3086 merge_connection_missions(
3087 &mut existing_serialized.missions,
3088 &serialized.missions,
3089 );
3090 } else {
3091 ordered_cnx.push((order, serialized));
3092 }
3093 }
3094 for node_idx in graph.0.node_indices() {
3095 let node = &graph.0[node_idx];
3096 if node.get_flavor() != Flavor::Task {
3097 continue;
3098 }
3099 for (msg, order) in node.nc_outputs_with_order() {
3100 let serialized = SerializedCnx {
3101 src: node.get_id(),
3102 dst: NC_ENDPOINT.to_string(),
3103 msg: msg.clone(),
3104 missions: Some(vec![mission_id.clone()]),
3105 };
3106 if let Some((existing_order, existing_serialized)) =
3107 ordered_cnx.iter_mut().find(|(_, c)| {
3108 c.src == serialized.src
3109 && c.dst == serialized.dst
3110 && c.msg == serialized.msg
3111 })
3112 {
3113 if order < *existing_order {
3114 *existing_order = order;
3115 }
3116 merge_connection_missions(
3117 &mut existing_serialized.missions,
3118 &serialized.missions,
3119 );
3120 } else {
3121 ordered_cnx.push((order, serialized));
3122 }
3123 }
3124 }
3125 }
3126 ordered_cnx.sort_by(|(order_a, cnx_a), (order_b, cnx_b)| {
3127 order_a
3128 .cmp(order_b)
3129 .then_with(|| cnx_a.src.cmp(&cnx_b.src))
3130 .then_with(|| cnx_a.dst.cmp(&cnx_b.dst))
3131 .then_with(|| cnx_a.msg.cmp(&cnx_b.msg))
3132 });
3133 let cnx: Vec<SerializedCnx> = ordered_cnx
3134 .into_iter()
3135 .map(|(_, serialized)| serialized)
3136 .collect();
3137
3138 CuConfigRepresentation {
3139 constants: (!self.constants.is_empty()).then_some(self.constants.clone()),
3140 tasks: Some(tasks),
3141 resources: resources.clone(),
3142 bridges,
3143 cnx: Some(cnx),
3144 monitors,
3145 logging: self.logging.clone(),
3146 runtime: self.runtime.clone(),
3147 missions: Some(missions),
3148 includes: None,
3149 }
3150 .serialize(serializer)
3151 }
3152 }
3153 }
3154}
3155
3156impl Default for CuConfig {
3157 fn default() -> Self {
3158 CuConfig {
3159 constants: Vec::new(),
3160 graphs: Simple(CuGraph(StableDiGraph::new())),
3161 monitors: Vec::new(),
3162 logging: None,
3163 runtime: None,
3164 resources: Vec::new(),
3165 bridges: Vec::new(),
3166 }
3167 }
3168}
3169
3170impl CuConfig {
3173 #[allow(dead_code)]
3174 pub fn new_simple_type() -> Self {
3175 Self::default()
3176 }
3177
3178 #[allow(dead_code)]
3179 pub fn new_mission_type() -> Self {
3180 CuConfig {
3181 constants: Vec::new(),
3182 graphs: Missions(HashMap::new()),
3183 monitors: Vec::new(),
3184 logging: None,
3185 runtime: None,
3186 resources: Vec::new(),
3187 bridges: Vec::new(),
3188 }
3189 }
3190
3191 fn get_options() -> Options {
3192 Options::default()
3193 .with_default_extension(Extensions::IMPLICIT_SOME)
3194 .with_default_extension(Extensions::UNWRAP_NEWTYPES)
3195 .with_default_extension(Extensions::UNWRAP_VARIANT_NEWTYPES)
3196 }
3197
3198 #[allow(dead_code)]
3199 pub fn serialize_ron(&self) -> CuResult<String> {
3200 let ron = Self::get_options();
3201 let pretty = ron::ser::PrettyConfig::default();
3202 ron.to_string_pretty(&self, pretty)
3203 .map_err(|e| CuError::from(format!("Error serializing configuration: {e}")))
3204 }
3205
3206 #[allow(dead_code)]
3207 pub fn deserialize_ron(ron: &str) -> CuResult<Self> {
3208 let representation = Self::get_options().from_str(ron).map_err(|e| {
3209 CuError::from(format!(
3210 "Syntax Error in config: {} at position {}",
3211 e.code, e.span
3212 ))
3213 })?;
3214 Self::deserialize_impl(representation)
3215 .map_err(|e| CuError::from(format!("Error deserializing configuration: {e}")))
3216 }
3217
3218 fn deserialize_impl(representation: CuConfigRepresentation) -> Result<Self, String> {
3219 deserialize_config_representation(&representation)
3220 }
3221
3222 #[cfg(feature = "std")]
3224 #[allow(dead_code)]
3225 pub fn render(
3226 &self,
3227 output: &mut dyn std::io::Write,
3228 mission_id: Option<&str>,
3229 ) -> CuResult<()> {
3230 writeln!(output, "digraph G {{")
3231 .map_err(|e| CuError::new_with_cause("Failed to write render output", e))?;
3232 writeln!(output, " graph [rankdir=LR, nodesep=0.8, ranksep=1.2];")
3233 .map_err(|e| CuError::new_with_cause("Failed to write render output", e))?;
3234 writeln!(output, " node [shape=plain, fontname=\"Noto Sans\"];")
3235 .map_err(|e| CuError::new_with_cause("Failed to write render output", e))?;
3236 writeln!(output, " edge [fontname=\"Noto Sans\"];")
3237 .map_err(|e| CuError::new_with_cause("Failed to write render output", e))?;
3238
3239 let sections = match (&self.graphs, mission_id) {
3240 (Simple(graph), _) => vec![RenderSection { label: None, graph }],
3241 (Missions(graphs), Some(id)) => {
3242 let graph = graphs
3243 .get(id)
3244 .ok_or_else(|| CuError::from(format!("Mission {id} not found")))?;
3245 vec![RenderSection {
3246 label: Some(id.to_string()),
3247 graph,
3248 }]
3249 }
3250 (Missions(graphs), None) => {
3251 let mut missions: Vec<_> = graphs.iter().collect();
3252 missions.sort_by(|a, b| a.0.cmp(b.0));
3253 missions
3254 .into_iter()
3255 .map(|(label, graph)| RenderSection {
3256 label: Some(label.clone()),
3257 graph,
3258 })
3259 .collect()
3260 }
3261 };
3262
3263 for section in sections {
3264 self.render_section(output, section.graph, section.label.as_deref())?;
3265 }
3266
3267 writeln!(output, "}}")
3268 .map_err(|e| CuError::new_with_cause("Failed to write render output", e))?;
3269 Ok(())
3270 }
3271
3272 #[allow(dead_code)]
3273 pub fn get_all_instances_configs(
3274 &self,
3275 mission_id: Option<&str>,
3276 ) -> Vec<Option<&ComponentConfig>> {
3277 let graph = self.graphs.get_graph(mission_id).unwrap();
3278 graph
3279 .get_all_nodes()
3280 .iter()
3281 .map(|(_, node)| node.get_instance_config())
3282 .collect()
3283 }
3284
3285 #[allow(dead_code)]
3286 pub fn get_graph(&self, mission_id: Option<&str>) -> CuResult<&CuGraph> {
3287 self.graphs.get_graph(mission_id)
3288 }
3289
3290 #[allow(dead_code)]
3291 pub fn get_graph_mut(&mut self, mission_id: Option<&str>) -> CuResult<&mut CuGraph> {
3292 self.graphs.get_graph_mut(mission_id)
3293 }
3294
3295 #[allow(dead_code)]
3296 pub fn get_monitor_config(&self) -> Option<&MonitorConfig> {
3297 self.monitors.first()
3298 }
3299
3300 #[allow(dead_code)]
3301 pub fn get_monitor_configs(&self) -> &[MonitorConfig] {
3302 &self.monitors
3303 }
3304
3305 #[allow(dead_code)]
3306 pub fn get_runtime_config(&self) -> Option<&RuntimeConfig> {
3307 self.runtime.as_ref()
3308 }
3309
3310 #[allow(dead_code)]
3311 pub fn find_task_node(&self, mission_id: Option<&str>, task_id: &str) -> Option<&Node> {
3312 self.get_graph(mission_id)
3313 .ok()?
3314 .get_all_nodes()
3315 .into_iter()
3316 .find_map(|(_, node)| {
3317 (node.get_flavor() == Flavor::Task && node.id == task_id).then_some(node)
3318 })
3319 }
3320
3321 #[allow(dead_code)]
3322 pub fn find_logging_codec_spec(&self, codec_id: &str) -> Option<&LoggingCodecSpec> {
3323 self.logging
3324 .as_ref()?
3325 .codecs
3326 .iter()
3327 .find(|spec| spec.id == codec_id)
3328 }
3329
3330 pub fn validate_constants(&self) -> CuResult<()> {
3332 fn validate_integer(
3333 id: &str,
3334 storage: ConstantStorage,
3335 number: ConstantNumber,
3336 ) -> CuResult<()> {
3337 let valid = match (storage, number) {
3338 (ConstantStorage::I8, ConstantNumber::Signed(value)) => i8::try_from(value).is_ok(),
3339 (ConstantStorage::I16, ConstantNumber::Signed(value)) => {
3340 i16::try_from(value).is_ok()
3341 }
3342 (ConstantStorage::I32, ConstantNumber::Signed(value)) => {
3343 i32::try_from(value).is_ok()
3344 }
3345 (ConstantStorage::I64, ConstantNumber::Signed(_)) => true,
3346 (ConstantStorage::Isize, ConstantNumber::Signed(value)) => {
3347 isize::try_from(value).is_ok()
3348 }
3349 (ConstantStorage::U8, ConstantNumber::Unsigned(value)) => {
3350 u8::try_from(value).is_ok()
3351 }
3352 (ConstantStorage::U16, ConstantNumber::Unsigned(value)) => {
3353 u16::try_from(value).is_ok()
3354 }
3355 (ConstantStorage::U32, ConstantNumber::Unsigned(value)) => {
3356 u32::try_from(value).is_ok()
3357 }
3358 (ConstantStorage::U64, ConstantNumber::Unsigned(_)) => true,
3359 (ConstantStorage::Usize, ConstantNumber::Unsigned(value)) => {
3360 usize::try_from(value).is_ok()
3361 }
3362 _ => false,
3363 };
3364 if valid {
3365 Ok(())
3366 } else {
3367 Err(CuError::from(format!(
3368 "Constant '{id}' value {number:?} cannot be represented as {}",
3369 storage.rust_type()
3370 )))
3371 }
3372 }
3373
3374 let mut ids = HashMap::new();
3375 for constant in &self.constants {
3376 if constant.id().is_empty() {
3377 return Err(CuError::from("Constant ids cannot be empty"));
3378 }
3379 if ids
3380 .insert((constant.module_path(), constant.id()), ())
3381 .is_some()
3382 {
3383 return Err(CuError::from(format!(
3384 "Duplicate constant '{}'. Constant ids must be unique within a module.",
3385 constant.qualified_id()
3386 )));
3387 }
3388
3389 match (
3390 constant.value.is_some(),
3391 constant.rust_type.as_deref(),
3392 constant.expression.as_deref(),
3393 ) {
3394 (true, None, None) => {}
3395 (true, _, _) => {
3396 return Err(CuError::from(format!(
3397 "Constant '{}' cannot combine numeric 'value' with 'type' or 'expression'",
3398 constant.id()
3399 )));
3400 }
3401 (false, Some(rust_type), Some(expression)) => {
3402 if constant.storage.is_some()
3403 || constant.quantity.is_some()
3404 || constant.unit.is_some()
3405 {
3406 return Err(CuError::from(format!(
3407 "Constant '{}' cannot combine 'type' and 'expression' with numeric 'storage', 'quantity', or 'unit'",
3408 constant.id()
3409 )));
3410 }
3411 if rust_type.trim().is_empty() {
3412 return Err(CuError::from(format!(
3413 "Constant '{}' type cannot be empty",
3414 constant.id()
3415 )));
3416 }
3417 if expression.trim().is_empty() {
3418 return Err(CuError::from(format!(
3419 "Constant '{}' expression cannot be empty",
3420 constant.id()
3421 )));
3422 }
3423 continue;
3424 }
3425 (false, Some(_), None) => {
3426 return Err(CuError::from(format!(
3427 "Constant '{}' declares 'type' without 'expression'",
3428 constant.id()
3429 )));
3430 }
3431 (false, None, Some(_)) => {
3432 return Err(CuError::from(format!(
3433 "Constant '{}' declares 'expression' without 'type'",
3434 constant.id()
3435 )));
3436 }
3437 (false, None, None) => {
3438 return Err(CuError::from(format!(
3439 "Constant '{}' must declare either numeric 'value' or both 'type' and 'expression'",
3440 constant.id()
3441 )));
3442 }
3443 }
3444
3445 if constant.quantity().is_none() && constant.explicit_unit().is_some() {
3446 return Err(CuError::from(format!(
3447 "Constant '{}' declares a unit without a quantity",
3448 constant.id()
3449 )));
3450 }
3451
3452 if constant.quantity().is_some() {
3453 if !constant.storage().supports_quantity() {
3454 return Err(CuError::from(format!(
3455 "Constant '{}' quantity '{}' requires storage f32 or f64, not {}",
3456 constant.id(),
3457 constant.quantity().map_or("", |quantity| quantity.name()),
3458 constant.storage().rust_type()
3459 )));
3460 }
3461 let normalized = match constant.storage() {
3462 ConstantStorage::F32 => constant.normalized_f32().map(|_| ()),
3463 ConstantStorage::F64 => constant.normalized_f64().map(|_| ()),
3464 _ => unreachable!("quantity storage was checked above"),
3465 };
3466 normalized.map_err(CuError::from)?;
3467 continue;
3468 }
3469
3470 let (_, numbers) = constant.numbers().map_err(CuError::from)?;
3471 for number in numbers {
3472 match constant.storage() {
3473 ConstantStorage::F32 => {
3474 if !(number.as_f64() as f32).is_finite() {
3475 return Err(CuError::from(format!(
3476 "Constant '{}' values must be finite",
3477 constant.id()
3478 )));
3479 }
3480 }
3481 ConstantStorage::F64 => {
3482 if !number.as_f64().is_finite() {
3483 return Err(CuError::from(format!(
3484 "Constant '{}' values must be finite",
3485 constant.id()
3486 )));
3487 }
3488 }
3489 storage => validate_integer(constant.id(), storage, number)?,
3490 }
3491 }
3492 }
3493 Ok(())
3494 }
3495
3496 pub fn validate_logging_config(&self) -> CuResult<()> {
3499 if let Some(logging) = &self.logging {
3500 return logging.validate();
3501 }
3502 Ok(())
3503 }
3504
3505 pub fn validate_runtime_config(&self) -> CuResult<()> {
3507 if let Some(runtime) = &self.runtime {
3508 return runtime.validate();
3509 }
3510 Ok(())
3511 }
3512
3513 pub fn validate_anytime_configs(&self) -> CuResult<()> {
3534 let rate_target_hz = self.runtime.as_ref().and_then(|r| r.rate_target_hz);
3535 match &self.graphs {
3536 Simple(graph) => validate_anytime_graph(graph, rate_target_hz),
3537 Missions(graphs) => {
3538 for graph in graphs.values() {
3539 validate_anytime_graph(graph, rate_target_hz)?;
3540 }
3541 Ok(())
3542 }
3543 }
3544 }
3545}
3546
3547fn validate_anytime_graph(graph: &CuGraph, rate_target_hz: Option<u64>) -> CuResult<()> {
3551 for (node_id, node) in graph.get_all_nodes() {
3552 let Some(anytime) = node.anytime() else {
3553 continue;
3554 };
3555 anytime.validate(&node.id)?;
3556
3557 let kind = resolve_task_kind_for_id(graph, node_id)?;
3558 if kind != TaskKind::Regular {
3559 return Err(CuError::from(format!(
3560 "Task '{}' is declared with an anytime: policy but resolves to kind '{}'. Anytime refinement needs both an input and an output, so it is only supported on regular tasks.",
3561 node.id,
3562 kind.as_str()
3563 )));
3564 }
3565
3566 let input_count = graph.get_dst_edges(node_id)?.len();
3571 if input_count != 1 {
3572 return Err(CuError::from(format!(
3573 "Task '{}' is an anytime task and must have exactly one input connection (found {input_count}): the runner anchors the job on the input's Tov.",
3574 node.id
3575 )));
3576 }
3577 let output_count = graph.get_node_output_msg_types_by_id(node_id)?.len();
3578 if output_count > 1 {
3579 return Err(CuError::from(format!(
3580 "Task '{}' is an anytime task and must have exactly one output message type (found {output_count}): base() and every refine() write the same output slot.",
3581 node.id
3582 )));
3583 }
3584
3585 if node.is_background() {
3588 continue;
3589 }
3590
3591 if anytime.max_refines.is_none() {
3595 return Err(CuError::from(format!(
3596 "Task '{}' is a foreground anytime task and needs anytime.max_refines: the execution plan is static, so the refine step count must be known at compile time. time_budget_ms/max_age_ms remain early-stop conditions within those quanta.",
3597 node.id
3598 )));
3599 }
3600
3601 let Some(rate_target_hz) = rate_target_hz else {
3602 continue;
3603 };
3604 let window_ms = match (anytime.time_budget_ms, anytime.max_age_ms) {
3605 (Some(budget), Some(age)) => budget.min(age),
3606 (Some(budget), None) => budget,
3607 (None, Some(age)) => age,
3608 (None, None) => {
3609 return Err(CuError::from(format!(
3610 "Task '{}' is a foreground anytime task in a rate-limited config and needs a time bound: set anytime.time_budget_ms or anytime.max_age_ms. max_refines alone gives the runtime no time quantity to check against the {rate_target_hz} Hz loop period, and one slow quantum would silently overrun it.",
3611 node.id
3612 )));
3613 }
3614 };
3615 let period_ms = 1_000.0 / rate_target_hz as f64;
3616 if window_ms >= period_ms {
3617 return Err(CuError::from(format!(
3618 "Task '{}': the worst-case anytime window ({window_ms} ms) does not fit within the {rate_target_hz} Hz loop period ({period_ms} ms) with headroom for the rest of the copperlist. Tighten the time bound, lower runtime.rate_target_hz, or run the task with background: true.",
3619 node.id
3620 )));
3621 }
3622 }
3623 Ok(())
3624}
3625
3626#[cfg(feature = "std")]
3627#[derive(Default)]
3628pub(crate) struct PortLookup {
3629 pub inputs: HashMap<String, String>,
3630 pub outputs: HashMap<String, String>,
3631 pub default_input: Option<String>,
3632 pub default_output: Option<String>,
3633}
3634
3635#[cfg(feature = "std")]
3636#[derive(Clone)]
3637pub(crate) struct RenderNode {
3638 pub id: String,
3639 pub type_name: String,
3640 pub flavor: Flavor,
3641 pub inputs: Vec<String>,
3642 pub outputs: Vec<String>,
3643}
3644
3645#[cfg(feature = "std")]
3646#[derive(Clone)]
3647pub(crate) struct RenderConnection {
3648 pub src: String,
3649 pub src_port: Option<String>,
3650 #[allow(dead_code)]
3651 pub src_channel: Option<String>,
3652 pub dst: String,
3653 pub dst_port: Option<String>,
3654 #[allow(dead_code)]
3655 pub dst_channel: Option<String>,
3656 pub msg: String,
3657}
3658
3659#[cfg(feature = "std")]
3660pub(crate) struct RenderTopology {
3661 pub nodes: Vec<RenderNode>,
3662 pub connections: Vec<RenderConnection>,
3663}
3664
3665#[cfg(feature = "std")]
3666impl RenderTopology {
3667 pub fn sort_connections(&mut self) {
3668 self.connections.sort_by(|a, b| {
3669 a.src
3670 .cmp(&b.src)
3671 .then(a.dst.cmp(&b.dst))
3672 .then(a.msg.cmp(&b.msg))
3673 });
3674 }
3675}
3676
3677#[cfg(feature = "std")]
3678#[allow(dead_code)]
3679struct RenderSection<'a> {
3680 label: Option<String>,
3681 graph: &'a CuGraph,
3682}
3683
3684#[cfg(feature = "std")]
3685impl CuConfig {
3686 #[allow(dead_code)]
3687 fn render_section(
3688 &self,
3689 output: &mut dyn std::io::Write,
3690 graph: &CuGraph,
3691 label: Option<&str>,
3692 ) -> CuResult<()> {
3693 use std::fmt::Write as FmtWrite;
3694
3695 let mut topology = build_render_topology(graph, &self.bridges);
3696 topology.nodes.sort_by(|a, b| a.id.cmp(&b.id));
3697 topology.sort_connections();
3698
3699 let cluster_id = label.map(|lbl| format!("cluster_{}", sanitize_identifier(lbl)));
3700 if let Some(ref cluster_id) = cluster_id {
3701 writeln!(output, " subgraph \"{cluster_id}\" {{")
3702 .map_err(|e| CuError::new_with_cause("Failed to write render output", e))?;
3703 writeln!(
3704 output,
3705 " label=<<B>Mission: {}</B>>;",
3706 encode_text(label.unwrap())
3707 )
3708 .map_err(|e| CuError::new_with_cause("Failed to write render output", e))?;
3709 writeln!(
3710 output,
3711 " labelloc=t; labeljust=l; color=\"#bbbbbb\"; style=\"rounded\"; margin=20;"
3712 )
3713 .map_err(|e| CuError::new_with_cause("Failed to write render output", e))?;
3714 }
3715 let indent = if cluster_id.is_some() {
3716 " "
3717 } else {
3718 " "
3719 };
3720 let node_prefix = label
3721 .map(|lbl| format!("{}__", sanitize_identifier(lbl)))
3722 .unwrap_or_default();
3723
3724 let mut port_lookup: HashMap<String, PortLookup> = HashMap::new();
3725 let mut id_lookup: HashMap<String, String> = HashMap::new();
3726
3727 for node in &topology.nodes {
3728 let node_idx = graph
3729 .get_node_id_by_name(node.id.as_str())
3730 .ok_or_else(|| CuError::from(format!("Node '{}' missing from graph", node.id)))?;
3731 let node_weight = graph
3732 .get_node(node_idx)
3733 .ok_or_else(|| CuError::from(format!("Node '{}' missing weight", node.id)))?;
3734
3735 let fillcolor = match node.flavor {
3736 Flavor::Bridge => "#faedcd",
3737 Flavor::Task => match resolve_task_kind_for_id(graph, node_idx)? {
3738 TaskKind::Source => "#ddefc7",
3739 TaskKind::Sink => "#cce0ff",
3740 TaskKind::Regular => "#f2f2f2",
3741 },
3742 };
3743
3744 let port_base = format!("{}{}", node_prefix, sanitize_identifier(&node.id));
3745 let (inputs_table, input_map, default_input) =
3746 build_port_table("Inputs", &node.inputs, &port_base, "in");
3747 let (outputs_table, output_map, default_output) =
3748 build_port_table("Outputs", &node.outputs, &port_base, "out");
3749 let config_html = node_weight.config.as_ref().and_then(build_config_table);
3750
3751 let mut label_html = String::new();
3752 write!(
3753 label_html,
3754 "<TABLE BORDER=\"0\" CELLBORDER=\"1\" CELLSPACING=\"0\" CELLPADDING=\"6\" COLOR=\"gray\" BGCOLOR=\"white\">"
3755 )
3756 .unwrap();
3757 write!(
3758 label_html,
3759 "<TR><TD COLSPAN=\"2\" ALIGN=\"LEFT\" BGCOLOR=\"{fillcolor}\"><FONT POINT-SIZE=\"12\"><B>{}</B></FONT><BR/><FONT COLOR=\"dimgray\">[{}]</FONT></TD></TR>",
3760 encode_text(&node.id),
3761 encode_text(&node.type_name)
3762 )
3763 .unwrap();
3764 write!(
3765 label_html,
3766 "<TR><TD ALIGN=\"LEFT\" VALIGN=\"TOP\">{inputs_table}</TD><TD ALIGN=\"LEFT\" VALIGN=\"TOP\">{outputs_table}</TD></TR>"
3767 )
3768 .unwrap();
3769
3770 if let Some(config_html) = config_html {
3771 write!(
3772 label_html,
3773 "<TR><TD COLSPAN=\"2\" ALIGN=\"LEFT\">{config_html}</TD></TR>"
3774 )
3775 .unwrap();
3776 }
3777
3778 label_html.push_str("</TABLE>");
3779
3780 let identifier_raw = if node_prefix.is_empty() {
3781 node.id.clone()
3782 } else {
3783 format!("{node_prefix}{}", node.id)
3784 };
3785 let identifier = escape_dot_id(&identifier_raw);
3786 writeln!(output, "{indent}\"{identifier}\" [label=<{label_html}>];")
3787 .map_err(|e| CuError::new_with_cause("Failed to write render output", e))?;
3788
3789 id_lookup.insert(node.id.clone(), identifier);
3790 port_lookup.insert(
3791 node.id.clone(),
3792 PortLookup {
3793 inputs: input_map,
3794 outputs: output_map,
3795 default_input,
3796 default_output,
3797 },
3798 );
3799 }
3800
3801 for cnx in &topology.connections {
3802 let src_id = id_lookup
3803 .get(&cnx.src)
3804 .ok_or_else(|| CuError::from(format!("Unknown node '{}'", cnx.src)))?;
3805 let dst_id = id_lookup
3806 .get(&cnx.dst)
3807 .ok_or_else(|| CuError::from(format!("Unknown node '{}'", cnx.dst)))?;
3808 let src_suffix = port_lookup
3809 .get(&cnx.src)
3810 .and_then(|lookup| lookup.resolve_output(cnx.src_port.as_deref()))
3811 .map(|port| format!(":\"{port}\":e"))
3812 .unwrap_or_default();
3813 let dst_suffix = port_lookup
3814 .get(&cnx.dst)
3815 .and_then(|lookup| lookup.resolve_input(cnx.dst_port.as_deref()))
3816 .map(|port| format!(":\"{port}\":w"))
3817 .unwrap_or_default();
3818 let msg = encode_text(&cnx.msg);
3819 writeln!(
3820 output,
3821 "{indent}\"{src_id}\"{src_suffix} -> \"{dst_id}\"{dst_suffix} [label=< <B><FONT COLOR=\"gray\">{msg}</FONT></B> >];"
3822 )
3823 .map_err(|e| CuError::new_with_cause("Failed to write render output", e))?;
3824 }
3825
3826 if cluster_id.is_some() {
3827 writeln!(output, " }}")
3828 .map_err(|e| CuError::new_with_cause("Failed to write render output", e))?;
3829 }
3830
3831 Ok(())
3832 }
3833}
3834
3835#[cfg(feature = "std")]
3836pub(crate) fn build_render_topology(graph: &CuGraph, bridges: &[BridgeConfig]) -> RenderTopology {
3837 let mut bridge_lookup = HashMap::new();
3838 for bridge in bridges {
3839 bridge_lookup.insert(bridge.id.as_str(), bridge);
3840 }
3841
3842 let mut nodes: Vec<RenderNode> = Vec::new();
3843 let mut node_lookup: HashMap<String, usize> = HashMap::new();
3844 for (node_idx, node) in graph.get_all_nodes() {
3845 let node_id = node.get_id();
3846 let mut inputs = Vec::new();
3847 let mut outputs = Vec::new();
3848 if node.get_flavor() == Flavor::Bridge
3849 && let Some(bridge) = bridge_lookup.get(node_id.as_str())
3850 {
3851 for channel in &bridge.channels {
3852 match channel {
3853 BridgeChannelConfigRepresentation::Rx { id, .. } => outputs.push(id.clone()),
3855 BridgeChannelConfigRepresentation::Tx { id, .. } => inputs.push(id.clone()),
3857 }
3858 }
3859 } else if node.get_flavor() == Flavor::Task {
3860 for (idx, msg) in graph
3861 .get_node_output_msg_types_by_id(node_idx)
3862 .unwrap_or_default()
3863 .into_iter()
3864 .enumerate()
3865 {
3866 outputs.push(format!("out{idx}: {msg}"));
3867 }
3868 }
3869
3870 node_lookup.insert(node_id.clone(), nodes.len());
3871 nodes.push(RenderNode {
3872 id: node_id,
3873 type_name: node.get_type().to_string(),
3874 flavor: node.get_flavor(),
3875 inputs,
3876 outputs,
3877 });
3878 }
3879
3880 let mut output_port_lookup: Vec<HashMap<String, String>> = vec![HashMap::new(); nodes.len()];
3881 for (node_idx, node) in graph.get_all_nodes() {
3882 let Some(&idx) = node_lookup.get(&node.get_id()) else {
3883 continue;
3884 };
3885 if node.get_flavor() != Flavor::Task {
3886 continue;
3887 }
3888 for (port_idx, msg) in graph
3889 .get_node_output_msg_types_by_id(node_idx)
3890 .unwrap_or_default()
3891 .into_iter()
3892 .enumerate()
3893 {
3894 output_port_lookup[idx].insert(msg.clone(), format!("out{port_idx}: {msg}"));
3895 }
3896 }
3897
3898 let mut auto_input_counts = vec![0usize; nodes.len()];
3899 for edge in graph.0.edge_references() {
3900 let cnx = edge.weight();
3901 if let Some(&idx) = node_lookup.get(&cnx.dst)
3902 && nodes[idx].flavor == Flavor::Task
3903 && cnx.dst_channel.is_none()
3904 {
3905 auto_input_counts[idx] += 1;
3906 }
3907 }
3908
3909 let mut next_auto_input = vec![0usize; nodes.len()];
3910 let mut connections = Vec::new();
3911 for edge in graph.0.edge_references() {
3912 let cnx = edge.weight();
3913 let mut src_port = cnx.src_channel.clone();
3914 let mut dst_port = cnx.dst_channel.clone();
3915
3916 if let Some(&idx) = node_lookup.get(&cnx.src) {
3917 let node = &mut nodes[idx];
3918 if node.flavor == Flavor::Task && src_port.is_none() {
3919 src_port = output_port_lookup[idx].get(&cnx.msg).cloned();
3920 }
3921 }
3922 if let Some(&idx) = node_lookup.get(&cnx.dst) {
3923 let node = &mut nodes[idx];
3924 if node.flavor == Flavor::Task && dst_port.is_none() {
3925 let count = auto_input_counts[idx];
3926 let next = if count <= 1 {
3927 "in".to_string()
3928 } else {
3929 let next = format!("in.{}", next_auto_input[idx]);
3930 next_auto_input[idx] += 1;
3931 next
3932 };
3933 node.inputs.push(next.clone());
3934 dst_port = Some(next);
3935 }
3936 }
3937
3938 connections.push(RenderConnection {
3939 src: cnx.src.clone(),
3940 src_port,
3941 src_channel: cnx.src_channel.clone(),
3942 dst: cnx.dst.clone(),
3943 dst_port,
3944 dst_channel: cnx.dst_channel.clone(),
3945 msg: cnx.msg.clone(),
3946 });
3947 }
3948
3949 RenderTopology { nodes, connections }
3950}
3951
3952#[cfg(feature = "std")]
3953impl PortLookup {
3954 pub fn resolve_input(&self, name: Option<&str>) -> Option<&str> {
3955 if let Some(name) = name
3956 && let Some(port) = self.inputs.get(name)
3957 {
3958 return Some(port.as_str());
3959 }
3960 self.default_input.as_deref()
3961 }
3962
3963 pub fn resolve_output(&self, name: Option<&str>) -> Option<&str> {
3964 if let Some(name) = name
3965 && let Some(port) = self.outputs.get(name)
3966 {
3967 return Some(port.as_str());
3968 }
3969 self.default_output.as_deref()
3970 }
3971}
3972
3973#[cfg(feature = "std")]
3974#[allow(dead_code)]
3975fn build_port_table(
3976 title: &str,
3977 names: &[String],
3978 base_id: &str,
3979 prefix: &str,
3980) -> (String, HashMap<String, String>, Option<String>) {
3981 use std::fmt::Write as FmtWrite;
3982
3983 let mut html = String::new();
3984 write!(
3985 html,
3986 "<TABLE BORDER=\"0\" CELLBORDER=\"0\" CELLSPACING=\"0\" CELLPADDING=\"1\">"
3987 )
3988 .unwrap();
3989 write!(
3990 html,
3991 "<TR><TD ALIGN=\"LEFT\"><FONT COLOR=\"dimgray\">{}</FONT></TD></TR>",
3992 encode_text(title)
3993 )
3994 .unwrap();
3995
3996 let mut lookup = HashMap::new();
3997 let mut default_port = None;
3998
3999 if names.is_empty() {
4000 html.push_str("<TR><TD ALIGN=\"LEFT\"><FONT COLOR=\"lightgray\">—</FONT></TD></TR>");
4001 } else {
4002 for (idx, name) in names.iter().enumerate() {
4003 let port_id = format!("{base_id}_{prefix}_{idx}");
4004 write!(
4005 html,
4006 "<TR><TD PORT=\"{port_id}\" ALIGN=\"LEFT\">{}</TD></TR>",
4007 encode_text(name)
4008 )
4009 .unwrap();
4010 lookup.insert(name.clone(), port_id.clone());
4011 if idx == 0 {
4012 default_port = Some(port_id);
4013 }
4014 }
4015 }
4016
4017 html.push_str("</TABLE>");
4018 (html, lookup, default_port)
4019}
4020
4021#[cfg(feature = "std")]
4022#[allow(dead_code)]
4023fn build_config_table(config: &ComponentConfig) -> Option<String> {
4024 use std::fmt::Write as FmtWrite;
4025
4026 if config.0.is_empty() {
4027 return None;
4028 }
4029
4030 let mut entries: Vec<_> = config.0.iter().collect();
4031 entries.sort_by(|a, b| a.0.cmp(b.0));
4032
4033 let mut html = String::new();
4034 html.push_str("<TABLE BORDER=\"0\" CELLBORDER=\"0\" CELLSPACING=\"0\" CELLPADDING=\"1\">");
4035 for (key, value) in entries {
4036 let value_txt = format!("{value}");
4037 write!(
4038 html,
4039 "<TR><TD ALIGN=\"LEFT\"><FONT COLOR=\"dimgray\">{}</FONT> = {}</TD></TR>",
4040 encode_text(key),
4041 encode_text(&value_txt)
4042 )
4043 .unwrap();
4044 }
4045 html.push_str("</TABLE>");
4046 Some(html)
4047}
4048
4049#[cfg(feature = "std")]
4050#[allow(dead_code)]
4051fn sanitize_identifier(value: &str) -> String {
4052 value
4053 .chars()
4054 .map(|c| if c.is_ascii_alphanumeric() { c } else { '_' })
4055 .collect()
4056}
4057
4058#[cfg(feature = "std")]
4059#[allow(dead_code)]
4060fn escape_dot_id(value: &str) -> String {
4061 let mut escaped = String::with_capacity(value.len());
4062 for ch in value.chars() {
4063 match ch {
4064 '"' => escaped.push_str("\\\""),
4065 '\\' => escaped.push_str("\\\\"),
4066 _ => escaped.push(ch),
4067 }
4068 }
4069 escaped
4070}
4071
4072impl LoggingConfig {
4073 pub fn validate(&self) -> CuResult<()> {
4075 if let Some(copperlist_count) = self.copperlist_count
4076 && copperlist_count == 0
4077 {
4078 return Err(CuError::from(
4079 "CopperList count cannot be zero. Set logging.copperlist_count to at least 1.",
4080 ));
4081 }
4082
4083 if let Some(section_size_mib) = self.section_size_mib
4084 && let Some(slab_size_mib) = self.slab_size_mib
4085 && section_size_mib > slab_size_mib
4086 {
4087 return Err(CuError::from(format!(
4088 "Section size ({section_size_mib} MiB) cannot be larger than slab size ({slab_size_mib} MiB). Adjust the parameters accordingly."
4089 )));
4090 }
4091
4092 let mut codec_ids = HashMap::new();
4093 for codec in &self.codecs {
4094 if codec_ids.insert(codec.id.as_str(), ()).is_some() {
4095 return Err(CuError::from(format!(
4096 "Duplicate logging codec id '{}'. Codec ids must be unique.",
4097 codec.id
4098 )));
4099 }
4100 }
4101
4102 Ok(())
4103 }
4104}
4105
4106impl RuntimeConfig {
4107 pub fn validate(&self) -> CuResult<()> {
4109 if let Some(rate_target_hz) = self.rate_target_hz {
4110 if rate_target_hz == 0 {
4111 return Err(CuError::from(
4112 "Runtime rate target cannot be zero. Set runtime.rate_target_hz to at least 1.",
4113 ));
4114 }
4115
4116 if rate_target_hz > MAX_RATE_TARGET_HZ {
4117 return Err(CuError::from(format!(
4118 "Runtime rate target ({rate_target_hz} Hz) exceeds the supported maximum of {MAX_RATE_TARGET_HZ} Hz."
4119 )));
4120 }
4121 }
4122
4123 Ok(())
4124 }
4125}
4126
4127#[allow(dead_code)] fn substitute_parameters(content: &str, params: &HashMap<String, Value>) -> String {
4129 let mut result = content.to_string();
4130
4131 for (key, value) in params {
4132 let pattern = format!("{{{{{key}}}}}");
4133 result = result.replace(&pattern, &value.to_string());
4134 }
4135
4136 result
4137}
4138
4139#[cfg(feature = "std")]
4141fn process_includes(
4142 file_path: &str,
4143 base_representation: CuConfigRepresentation,
4144 processed_files: &mut Vec<String>,
4145 active_features: &[&str],
4146) -> CuResult<CuConfigRepresentation> {
4147 processed_files.push(file_path.to_string());
4149
4150 let mut result = base_representation;
4151
4152 if let Some(includes) = result.includes.take() {
4153 for include in includes {
4154 if include
4155 .when
4156 .as_ref()
4157 .is_some_and(|predicate| !predicate.evaluate(active_features))
4158 {
4159 continue;
4160 }
4161
4162 let include_path = if include.path.starts_with('/') {
4163 include.path.clone()
4164 } else {
4165 let current_dir = std::path::Path::new(file_path).parent();
4166
4167 match current_dir.map(|path| path.to_string_lossy().to_string()) {
4168 Some(current_dir) if !current_dir.is_empty() => {
4169 format!("{}/{}", current_dir, include.path)
4170 }
4171 _ => include.path,
4172 }
4173 };
4174
4175 let include_content = read_to_string(&include_path).map_err(|e| {
4176 CuError::from(format!("Failed to read include file: {include_path}"))
4177 .add_cause(e.to_string().as_str())
4178 })?;
4179
4180 let processed_content = substitute_parameters(&include_content, &include.params);
4181
4182 let mut included_representation: CuConfigRepresentation = match Options::default()
4183 .with_default_extension(Extensions::IMPLICIT_SOME)
4184 .with_default_extension(Extensions::UNWRAP_NEWTYPES)
4185 .with_default_extension(Extensions::UNWRAP_VARIANT_NEWTYPES)
4186 .from_str(&processed_content)
4187 {
4188 Ok(rep) => rep,
4189 Err(e) => {
4190 return Err(CuError::from(format!(
4191 "Failed to parse include file: {} - Error: {} at position {}",
4192 include_path, e.code, e.span
4193 )));
4194 }
4195 };
4196
4197 included_representation = process_includes(
4198 &include_path,
4199 included_representation,
4200 processed_files,
4201 active_features,
4202 )?;
4203
4204 if let Some(included_constants) = included_representation.constants {
4205 if result.constants.is_none() {
4206 result.constants = Some(included_constants);
4207 } else {
4208 let mut constants = result.constants.take().unwrap();
4209 for included_constant in included_constants {
4210 if !constants.iter().any(|constant| {
4211 constant.id == included_constant.id
4212 && constant.module_path() == included_constant.module_path()
4213 }) {
4214 constants.push(included_constant);
4215 }
4216 }
4217 result.constants = Some(constants);
4218 }
4219 }
4220
4221 if let Some(included_tasks) = included_representation.tasks {
4222 if result.tasks.is_none() {
4223 result.tasks = Some(included_tasks);
4224 } else {
4225 let mut tasks = result.tasks.take().unwrap();
4226 for included_task in included_tasks {
4227 if !tasks.iter().any(|t| t.id == included_task.id) {
4228 tasks.push(included_task);
4229 }
4230 }
4231 result.tasks = Some(tasks);
4232 }
4233 }
4234
4235 if let Some(included_bridges) = included_representation.bridges {
4236 if result.bridges.is_none() {
4237 result.bridges = Some(included_bridges);
4238 } else {
4239 let mut bridges = result.bridges.take().unwrap();
4240 for included_bridge in included_bridges {
4241 if !bridges.iter().any(|b| b.id == included_bridge.id) {
4242 bridges.push(included_bridge);
4243 }
4244 }
4245 result.bridges = Some(bridges);
4246 }
4247 }
4248
4249 if let Some(included_resources) = included_representation.resources {
4250 if result.resources.is_none() {
4251 result.resources = Some(included_resources);
4252 } else {
4253 let mut resources = result.resources.take().unwrap();
4254 for included_resource in included_resources {
4255 if !resources.iter().any(|r| r.id == included_resource.id) {
4256 resources.push(included_resource);
4257 }
4258 }
4259 result.resources = Some(resources);
4260 }
4261 }
4262
4263 if let Some(included_cnx) = included_representation.cnx {
4264 if result.cnx.is_none() {
4265 result.cnx = Some(included_cnx);
4266 } else {
4267 let mut cnx = result.cnx.take().unwrap();
4268 for included_c in included_cnx {
4269 if let Some(existing_cnx) = cnx.iter_mut().find(|c| {
4270 c.src == included_c.src
4271 && c.dst == included_c.dst
4272 && c.msg == included_c.msg
4273 }) {
4274 merge_connection_missions(
4275 &mut existing_cnx.missions,
4276 &included_c.missions,
4277 );
4278 } else {
4279 cnx.push(included_c);
4280 }
4281 }
4282 result.cnx = Some(cnx);
4283 }
4284 }
4285
4286 if let Some(included_monitors) = included_representation.monitors {
4287 if result.monitors.is_none() {
4288 result.monitors = Some(included_monitors);
4289 } else {
4290 let mut monitors = result.monitors.take().unwrap();
4291 for included_monitor in included_monitors {
4292 if !monitors.iter().any(|m| m.type_ == included_monitor.type_) {
4293 monitors.push(included_monitor);
4294 }
4295 }
4296 result.monitors = Some(monitors);
4297 }
4298 }
4299
4300 if result.logging.is_none() {
4301 result.logging = included_representation.logging;
4302 }
4303
4304 if result.runtime.is_none() {
4305 result.runtime = included_representation.runtime;
4306 }
4307
4308 if let Some(included_missions) = included_representation.missions {
4309 if result.missions.is_none() {
4310 result.missions = Some(included_missions);
4311 } else {
4312 let mut missions = result.missions.take().unwrap();
4313 for included_mission in included_missions {
4314 if !missions.iter().any(|m| m.id == included_mission.id) {
4315 missions.push(included_mission);
4316 }
4317 }
4318 result.missions = Some(missions);
4319 }
4320 }
4321 }
4322 }
4323
4324 Ok(result)
4325}
4326
4327#[cfg(feature = "std")]
4328fn parse_instance_config_overrides_string(
4329 content: &str,
4330) -> CuResult<InstanceConfigOverridesRepresentation> {
4331 Options::default()
4332 .with_default_extension(Extensions::IMPLICIT_SOME)
4333 .with_default_extension(Extensions::UNWRAP_NEWTYPES)
4334 .with_default_extension(Extensions::UNWRAP_VARIANT_NEWTYPES)
4335 .from_str(content)
4336 .map_err(|e| {
4337 CuError::from(format!(
4338 "Failed to parse instance override file: Error: {} at position {}",
4339 e.code, e.span
4340 ))
4341 })
4342}
4343
4344#[cfg(feature = "std")]
4345fn merge_component_config(target: &mut Option<ComponentConfig>, value: &ComponentConfig) {
4346 if let Some(existing) = target {
4347 existing.merge_from(value);
4348 } else {
4349 *target = Some(value.clone());
4350 }
4351}
4352
4353#[cfg(feature = "std")]
4354fn apply_task_config_override_to_graph(
4355 graph: &mut CuGraph,
4356 task_id: &str,
4357 value: &ComponentConfig,
4358) -> usize {
4359 let mut matches = 0usize;
4360 let node_indices: Vec<_> = graph.0.node_indices().collect();
4361 for node_index in node_indices {
4362 let node = &mut graph.0[node_index];
4363 if node.get_flavor() == Flavor::Task && node.id == task_id {
4364 merge_component_config(&mut node.config, value);
4365 matches += 1;
4366 }
4367 }
4368 matches
4369}
4370
4371#[cfg(feature = "std")]
4372fn apply_bridge_node_config_override_to_graph(
4373 graph: &mut CuGraph,
4374 bridge_id: &str,
4375 value: &ComponentConfig,
4376) {
4377 let node_indices: Vec<_> = graph.0.node_indices().collect();
4378 for node_index in node_indices {
4379 let node = &mut graph.0[node_index];
4380 if node.get_flavor() == Flavor::Bridge && node.id == bridge_id {
4381 merge_component_config(&mut node.config, value);
4382 }
4383 }
4384}
4385
4386#[cfg(feature = "std")]
4387fn parse_instance_override_target(path: &str) -> CuResult<(InstanceConfigTargetKind, String)> {
4388 let mut parts = path.split('/');
4389 let scope = parts.next().unwrap_or_default();
4390 let id = parts.next().unwrap_or_default();
4391 let leaf = parts.next().unwrap_or_default();
4392
4393 if scope.is_empty() || id.is_empty() || leaf.is_empty() || parts.next().is_some() {
4394 return Err(CuError::from(format!(
4395 "Invalid instance override path '{}'. Expected 'tasks/<id>/config', 'resources/<id>/config', or 'bridges/<id>/config'.",
4396 path
4397 )));
4398 }
4399
4400 if leaf != "config" {
4401 return Err(CuError::from(format!(
4402 "Invalid instance override path '{}'. Only the '/config' leaf is supported.",
4403 path
4404 )));
4405 }
4406
4407 let kind = match scope {
4408 "tasks" => InstanceConfigTargetKind::Task,
4409 "resources" => InstanceConfigTargetKind::Resource,
4410 "bridges" => InstanceConfigTargetKind::Bridge,
4411 _ => {
4412 return Err(CuError::from(format!(
4413 "Invalid instance override path '{}'. Supported roots are 'tasks', 'resources', and 'bridges'.",
4414 path
4415 )));
4416 }
4417 };
4418
4419 Ok((kind, id.to_string()))
4420}
4421
4422#[cfg(feature = "std")]
4423fn apply_instance_config_set_operation(
4424 config: &mut CuConfig,
4425 operation: &InstanceConfigSetOperation,
4426) -> CuResult<()> {
4427 let (target_kind, target_id) = parse_instance_override_target(&operation.path)?;
4428
4429 match target_kind {
4430 InstanceConfigTargetKind::Task => {
4431 let matches = match &mut config.graphs {
4432 ConfigGraphs::Simple(graph) => {
4433 apply_task_config_override_to_graph(graph, &target_id, &operation.value)
4434 }
4435 ConfigGraphs::Missions(graphs) => graphs
4436 .values_mut()
4437 .map(|graph| {
4438 apply_task_config_override_to_graph(graph, &target_id, &operation.value)
4439 })
4440 .sum(),
4441 };
4442
4443 if matches == 0 {
4444 return Err(CuError::from(format!(
4445 "Instance override path '{}' targets unknown task '{}'.",
4446 operation.path, target_id
4447 )));
4448 }
4449 }
4450 InstanceConfigTargetKind::Resource => {
4451 let mut matches = 0usize;
4452 for resource in &mut config.resources {
4453 if resource.id == target_id {
4454 merge_component_config(&mut resource.config, &operation.value);
4455 matches += 1;
4456 }
4457 }
4458 if matches == 0 {
4459 return Err(CuError::from(format!(
4460 "Instance override path '{}' targets unknown resource '{}'.",
4461 operation.path, target_id
4462 )));
4463 }
4464 }
4465 InstanceConfigTargetKind::Bridge => {
4466 let mut matches = 0usize;
4467 for bridge in &mut config.bridges {
4468 if bridge.id == target_id {
4469 merge_component_config(&mut bridge.config, &operation.value);
4470 matches += 1;
4471 }
4472 }
4473 if matches == 0 {
4474 return Err(CuError::from(format!(
4475 "Instance override path '{}' targets unknown bridge '{}'.",
4476 operation.path, target_id
4477 )));
4478 }
4479
4480 match &mut config.graphs {
4481 ConfigGraphs::Simple(graph) => {
4482 apply_bridge_node_config_override_to_graph(graph, &target_id, &operation.value);
4483 }
4484 ConfigGraphs::Missions(graphs) => {
4485 for graph in graphs.values_mut() {
4486 apply_bridge_node_config_override_to_graph(
4487 graph,
4488 &target_id,
4489 &operation.value,
4490 );
4491 }
4492 }
4493 }
4494 }
4495 }
4496
4497 Ok(())
4498}
4499
4500#[cfg(feature = "std")]
4501fn apply_instance_overrides(
4502 config: &mut CuConfig,
4503 overrides: &InstanceConfigOverridesRepresentation,
4504) -> CuResult<()> {
4505 for operation in &overrides.set {
4506 apply_instance_config_set_operation(config, operation)?;
4507 }
4508 Ok(())
4509}
4510
4511#[cfg(feature = "std")]
4512fn apply_instance_overrides_from_file(
4513 config: &mut CuConfig,
4514 override_path: &std::path::Path,
4515) -> CuResult<()> {
4516 let override_content = read_to_string(override_path).map_err(|e| {
4517 CuError::from(format!(
4518 "Failed to read instance override file '{}'",
4519 override_path.display()
4520 ))
4521 .add_cause(e.to_string().as_str())
4522 })?;
4523 let overrides = parse_instance_config_overrides_string(&override_content).map_err(|e| {
4524 CuError::from(format!(
4525 "Failed to parse instance override file '{}': {e}",
4526 override_path.display()
4527 ))
4528 })?;
4529 apply_instance_overrides(config, &overrides)
4530}
4531
4532#[cfg(feature = "std")]
4533#[allow(dead_code)]
4534fn parse_multi_config_string(content: &str) -> CuResult<MultiCopperConfigRepresentation> {
4535 Options::default()
4536 .with_default_extension(Extensions::IMPLICIT_SOME)
4537 .with_default_extension(Extensions::UNWRAP_NEWTYPES)
4538 .with_default_extension(Extensions::UNWRAP_VARIANT_NEWTYPES)
4539 .from_str(content)
4540 .map_err(|e| {
4541 CuError::from(format!(
4542 "Failed to parse multi-Copper configuration: Error: {} at position {}",
4543 e.code, e.span
4544 ))
4545 })
4546}
4547
4548#[cfg(feature = "std")]
4549#[allow(dead_code)]
4550fn resolve_relative_config_path(base_path: Option<&str>, referenced_path: &str) -> String {
4551 if referenced_path.starts_with('/') || base_path.is_none() {
4552 return referenced_path.to_string();
4553 }
4554
4555 let current_dir = std::path::Path::new(base_path.expect("checked above"))
4556 .parent()
4557 .unwrap_or_else(|| std::path::Path::new(""))
4558 .to_path_buf();
4559 current_dir
4560 .join(referenced_path)
4561 .to_string_lossy()
4562 .to_string()
4563}
4564
4565#[cfg(feature = "std")]
4566#[allow(dead_code)]
4567fn parse_multi_endpoint(endpoint: &str) -> CuResult<MultiCopperEndpoint> {
4568 let mut parts = endpoint.split('/');
4569 let subsystem_id = parts.next().unwrap_or_default();
4570 let bridge_id = parts.next().unwrap_or_default();
4571 let channel_id = parts.next().unwrap_or_default();
4572
4573 if subsystem_id.is_empty()
4574 || bridge_id.is_empty()
4575 || channel_id.is_empty()
4576 || parts.next().is_some()
4577 {
4578 return Err(CuError::from(format!(
4579 "Invalid multi-Copper endpoint '{endpoint}'. Expected 'subsystem/bridge/channel'."
4580 )));
4581 }
4582
4583 Ok(MultiCopperEndpoint {
4584 subsystem_id: subsystem_id.to_string(),
4585 bridge_id: bridge_id.to_string(),
4586 channel_id: channel_id.to_string(),
4587 })
4588}
4589
4590#[cfg(feature = "std")]
4591#[allow(dead_code)]
4592fn multi_channel_key(bridge_id: &str, channel_id: &str) -> String {
4593 format!("{bridge_id}/{channel_id}")
4594}
4595
4596#[cfg(feature = "std")]
4597#[allow(dead_code)]
4598fn register_multi_channel_msg(
4599 contracts: &mut HashMap<String, MultiCopperChannelContract>,
4600 bridge_id: &str,
4601 channel_id: &str,
4602 expected_direction: MultiCopperChannelDirection,
4603 msg: &str,
4604) -> CuResult<()> {
4605 let key = multi_channel_key(bridge_id, channel_id);
4606 let contract = contracts.get_mut(&key).ok_or_else(|| {
4607 CuError::from(format!(
4608 "Bridge channel '{bridge_id}/{channel_id}' is referenced by the graph but not declared in the bridge config."
4609 ))
4610 })?;
4611
4612 if contract.direction != expected_direction {
4613 let expected = match expected_direction {
4614 MultiCopperChannelDirection::Rx => "Rx",
4615 MultiCopperChannelDirection::Tx => "Tx",
4616 };
4617 return Err(CuError::from(format!(
4618 "Bridge channel '{bridge_id}/{channel_id}' is used as {expected} in the graph but declared with the opposite direction."
4619 )));
4620 }
4621
4622 match &contract.msg {
4623 Some(existing) if existing != msg => Err(CuError::from(format!(
4624 "Bridge channel '{bridge_id}/{channel_id}' carries inconsistent message types '{existing}' and '{msg}'."
4625 ))),
4626 Some(_) => Ok(()),
4627 None => {
4628 contract.msg = Some(msg.to_string());
4629 Ok(())
4630 }
4631 }
4632}
4633
4634#[cfg(feature = "std")]
4635#[allow(dead_code)]
4636fn build_multi_bridge_channel_contracts(
4637 config: &CuConfig,
4638) -> CuResult<HashMap<String, MultiCopperChannelContract>> {
4639 let graph = config
4640 .graphs
4641 .get_graph(Some(DEFAULT_MISSION_ID))
4642 .map_err(|e| {
4643 CuError::from(format!(
4644 "Multi-Copper subsystem configs with missions must define a '{DEFAULT_MISSION_ID}' mission: {e}"
4645 ))
4646 })?;
4647
4648 let mut contracts = HashMap::new();
4649 for bridge in &config.bridges {
4650 for channel in &bridge.channels {
4651 let (channel_id, direction) = match channel {
4652 BridgeChannelConfigRepresentation::Rx { id, .. } => {
4653 (id.as_str(), MultiCopperChannelDirection::Rx)
4654 }
4655 BridgeChannelConfigRepresentation::Tx { id, .. } => {
4656 (id.as_str(), MultiCopperChannelDirection::Tx)
4657 }
4658 };
4659
4660 let key = multi_channel_key(&bridge.id, channel_id);
4661 if contracts.contains_key(&key) {
4662 return Err(CuError::from(format!(
4663 "Duplicate bridge channel declaration for '{key}'."
4664 )));
4665 }
4666
4667 contracts.insert(
4668 key,
4669 MultiCopperChannelContract {
4670 bridge_type: bridge.type_.clone(),
4671 direction,
4672 msg: None,
4673 },
4674 );
4675 }
4676 }
4677
4678 for edge in graph.edges() {
4679 if let Some(channel_id) = &edge.src_channel {
4680 register_multi_channel_msg(
4681 &mut contracts,
4682 &edge.src,
4683 channel_id,
4684 MultiCopperChannelDirection::Rx,
4685 &edge.msg,
4686 )?;
4687 }
4688 if let Some(channel_id) = &edge.dst_channel {
4689 register_multi_channel_msg(
4690 &mut contracts,
4691 &edge.dst,
4692 channel_id,
4693 MultiCopperChannelDirection::Tx,
4694 &edge.msg,
4695 )?;
4696 }
4697 }
4698
4699 Ok(contracts)
4700}
4701
4702#[cfg(feature = "std")]
4703#[allow(dead_code)]
4704fn validate_multi_config_representation(
4705 representation: MultiCopperConfigRepresentation,
4706 file_path: Option<&str>,
4707 active_features: &[&str],
4708) -> CuResult<MultiCopperConfig> {
4709 if representation
4710 .instance_overrides_root
4711 .as_ref()
4712 .is_some_and(|root| root.trim().is_empty())
4713 {
4714 return Err(CuError::from(
4715 "Multi-Copper instance_overrides_root must not be empty.",
4716 ));
4717 }
4718
4719 if representation.subsystems.is_empty() {
4720 return Err(CuError::from(
4721 "Multi-Copper config must declare at least one subsystem.",
4722 ));
4723 }
4724 if representation.subsystems.len() > usize::from(u16::MAX) + 1 {
4725 return Err(CuError::from(
4726 "Multi-Copper config supports at most 65536 distinct subsystem ids.",
4727 ));
4728 }
4729
4730 let mut seen_subsystems = std::collections::HashSet::new();
4731 for subsystem in &representation.subsystems {
4732 if subsystem.id.trim().is_empty() {
4733 return Err(CuError::from(
4734 "Multi-Copper subsystem ids must not be empty.",
4735 ));
4736 }
4737 if !seen_subsystems.insert(subsystem.id.clone()) {
4738 return Err(CuError::from(format!(
4739 "Duplicate multi-Copper subsystem id '{}'.",
4740 subsystem.id
4741 )));
4742 }
4743 }
4744
4745 let mut sorted_ids: Vec<_> = representation
4746 .subsystems
4747 .iter()
4748 .map(|subsystem| subsystem.id.clone())
4749 .collect();
4750 sorted_ids.sort();
4751 let subsystem_code_map: HashMap<_, _> = sorted_ids
4752 .into_iter()
4753 .enumerate()
4754 .map(|(idx, id)| {
4755 (
4756 id,
4757 u16::try_from(idx).expect("subsystem count was validated against u16 range"),
4758 )
4759 })
4760 .collect();
4761
4762 let mut subsystem_contracts: HashMap<String, HashMap<String, MultiCopperChannelContract>> =
4763 HashMap::new();
4764 let mut subsystems = Vec::with_capacity(representation.subsystems.len());
4765
4766 for subsystem in representation.subsystems {
4767 let resolved_config_path = resolve_relative_config_path(file_path, &subsystem.config);
4768 let config = read_configuration_with_features(&resolved_config_path, active_features)
4769 .map_err(|e| {
4770 CuError::from(format!(
4771 "Failed to read subsystem '{}' from '{}': {e}",
4772 subsystem.id, resolved_config_path
4773 ))
4774 })?;
4775 let contracts = build_multi_bridge_channel_contracts(&config).map_err(|e| {
4776 CuError::from(format!(
4777 "Invalid subsystem '{}' for multi-Copper validation: {e}",
4778 subsystem.id
4779 ))
4780 })?;
4781 subsystem_contracts.insert(subsystem.id.clone(), contracts);
4782 subsystems.push(MultiCopperSubsystem {
4783 subsystem_code: *subsystem_code_map
4784 .get(&subsystem.id)
4785 .expect("subsystem code map must contain every subsystem"),
4786 id: subsystem.id,
4787 config_path: resolved_config_path,
4788 config,
4789 });
4790 }
4791
4792 let mut interconnects = Vec::with_capacity(representation.interconnects.len());
4793 for interconnect in representation.interconnects {
4794 if interconnect
4795 .when
4796 .as_ref()
4797 .is_some_and(|predicate| !predicate.evaluate(active_features))
4798 {
4799 continue;
4800 }
4801
4802 let from = parse_multi_endpoint(&interconnect.from).map_err(|e| {
4803 CuError::from(format!(
4804 "Invalid multi-Copper interconnect source '{}': {e}",
4805 interconnect.from
4806 ))
4807 })?;
4808 let to = parse_multi_endpoint(&interconnect.to).map_err(|e| {
4809 CuError::from(format!(
4810 "Invalid multi-Copper interconnect destination '{}': {e}",
4811 interconnect.to
4812 ))
4813 })?;
4814
4815 let from_contracts = subsystem_contracts.get(&from.subsystem_id).ok_or_else(|| {
4816 CuError::from(format!(
4817 "Interconnect source '{}' references unknown subsystem '{}'.",
4818 from, from.subsystem_id
4819 ))
4820 })?;
4821 let to_contracts = subsystem_contracts.get(&to.subsystem_id).ok_or_else(|| {
4822 CuError::from(format!(
4823 "Interconnect destination '{}' references unknown subsystem '{}'.",
4824 to, to.subsystem_id
4825 ))
4826 })?;
4827
4828 let from_contract = from_contracts
4829 .get(&multi_channel_key(&from.bridge_id, &from.channel_id))
4830 .ok_or_else(|| {
4831 CuError::from(format!(
4832 "Interconnect source '{}' references unknown bridge channel.",
4833 from
4834 ))
4835 })?;
4836 let to_contract = to_contracts
4837 .get(&multi_channel_key(&to.bridge_id, &to.channel_id))
4838 .ok_or_else(|| {
4839 CuError::from(format!(
4840 "Interconnect destination '{}' references unknown bridge channel.",
4841 to
4842 ))
4843 })?;
4844
4845 if from_contract.direction != MultiCopperChannelDirection::Tx {
4846 return Err(CuError::from(format!(
4847 "Interconnect source '{}' must reference a Tx bridge channel.",
4848 from
4849 )));
4850 }
4851 if to_contract.direction != MultiCopperChannelDirection::Rx {
4852 return Err(CuError::from(format!(
4853 "Interconnect destination '{}' must reference an Rx bridge channel.",
4854 to
4855 )));
4856 }
4857
4858 if from_contract.bridge_type != to_contract.bridge_type {
4859 return Err(CuError::from(format!(
4860 "Interconnect '{}' -> '{}' mixes incompatible bridge types '{}' and '{}'.",
4861 from, to, from_contract.bridge_type, to_contract.bridge_type
4862 )));
4863 }
4864
4865 let from_msg = from_contract.msg.as_ref().ok_or_else(|| {
4866 CuError::from(format!(
4867 "Interconnect source '{}' is not wired inside subsystem '{}', so its message type cannot be inferred.",
4868 from, from.subsystem_id
4869 ))
4870 })?;
4871 let to_msg = to_contract.msg.as_ref().ok_or_else(|| {
4872 CuError::from(format!(
4873 "Interconnect destination '{}' is not wired inside subsystem '{}', so its message type cannot be inferred.",
4874 to, to.subsystem_id
4875 ))
4876 })?;
4877
4878 if from_msg != to_msg {
4879 return Err(CuError::from(format!(
4880 "Interconnect '{}' -> '{}' connects incompatible message types '{}' and '{}'.",
4881 from, to, from_msg, to_msg
4882 )));
4883 }
4884 if interconnect.msg != *from_msg {
4885 return Err(CuError::from(format!(
4886 "Interconnect '{}' -> '{}' declares message type '{}' but subsystem graphs require '{}'.",
4887 from, to, interconnect.msg, from_msg
4888 )));
4889 }
4890
4891 interconnects.push(MultiCopperInterconnect {
4892 from,
4893 to,
4894 msg: interconnect.msg,
4895 bridge_type: from_contract.bridge_type.clone(),
4896 });
4897 }
4898
4899 let instance_overrides_root = representation
4900 .instance_overrides_root
4901 .as_ref()
4902 .map(|root| resolve_relative_config_path(file_path, root));
4903
4904 Ok(MultiCopperConfig {
4905 subsystems,
4906 interconnects,
4907 instance_overrides_root,
4908 })
4909}
4910
4911#[cfg(feature = "std")]
4913pub fn read_configuration(config_filename: &str) -> CuResult<CuConfig> {
4914 read_configuration_with_features(config_filename, &[])
4915}
4916
4917#[cfg(feature = "std")]
4919pub fn read_configuration_with_features(
4920 config_filename: &str,
4921 active_features: &[&str],
4922) -> CuResult<CuConfig> {
4923 let config_content = read_configuration_content(config_filename)?;
4924 read_configuration_str_with_features(config_content, Some(config_filename), active_features)
4925}
4926
4927#[cfg(feature = "std")]
4928fn read_configuration_content(config_filename: &str) -> CuResult<String> {
4929 read_to_string(config_filename).map_err(|e| {
4930 CuError::from(format!(
4931 "Failed to read configuration file: {:?}",
4932 config_filename
4933 ))
4934 .add_cause(e.to_string().as_str())
4935 })
4936}
4937
4938fn parse_config_string(content: &str) -> CuResult<CuConfigRepresentation> {
4942 Options::default()
4943 .with_default_extension(Extensions::IMPLICIT_SOME)
4944 .with_default_extension(Extensions::UNWRAP_NEWTYPES)
4945 .with_default_extension(Extensions::UNWRAP_VARIANT_NEWTYPES)
4946 .from_str(content)
4947 .map_err(|e| {
4948 CuError::from(format!(
4949 "Failed to parse configuration: Error: {} at position {}",
4950 e.code, e.span
4951 ))
4952 })
4953}
4954
4955fn config_representation_to_config(representation: CuConfigRepresentation) -> CuResult<CuConfig> {
4958 #[allow(unused_mut)]
4959 let mut cuconfig = CuConfig::deserialize_impl(representation)
4960 .map_err(|e| CuError::from(format!("Error deserializing configuration: {e}")))?;
4961
4962 #[cfg(feature = "std")]
4963 cuconfig.ensure_default_background_pool();
4964
4965 cuconfig.validate_logging_config()?;
4966 cuconfig.validate_runtime_config()?;
4967 cuconfig.validate_anytime_configs()?;
4968 cuconfig.validate_constants()?;
4969
4970 Ok(cuconfig)
4971}
4972
4973#[allow(unused_variables)]
4974fn resolve_configuration_representation(
4975 config_content: &str,
4976 file_path: Option<&str>,
4977 active_features: &[&str],
4978) -> CuResult<CuConfigRepresentation> {
4979 let representation = parse_config_string(config_content)?;
4981
4982 #[cfg(feature = "std")]
4985 let representation = if let Some(path) = file_path {
4986 process_includes(path, representation, &mut Vec::new(), active_features)?
4987 } else {
4988 representation
4989 };
4990
4991 Ok(representation)
4992}
4993
4994#[cfg(feature = "std")]
4999#[doc(hidden)]
5000#[allow(dead_code)]
5001pub fn read_configuration_with_resolved_ron(config_filename: &str) -> CuResult<(CuConfig, String)> {
5002 read_configuration_with_resolved_ron_and_features(config_filename, &[])
5003}
5004
5005#[cfg(feature = "std")]
5007#[doc(hidden)]
5008pub fn read_configuration_with_resolved_ron_and_features(
5009 config_filename: &str,
5010 active_features: &[&str],
5011) -> CuResult<(CuConfig, String)> {
5012 let config_content = read_configuration_content(config_filename)?;
5013 let representation = resolve_configuration_representation(
5014 &config_content,
5015 Some(config_filename),
5016 active_features,
5017 )?;
5018 let resolved_ron = CuConfig::get_options()
5019 .to_string_pretty(&representation, ron::ser::PrettyConfig::default())
5020 .map_err(|e| CuError::from(format!("Error serializing configuration: {e}")))?;
5021 let config = config_representation_to_config(representation)?;
5022 Ok((config, resolved_ron))
5023}
5024
5025#[allow(dead_code)]
5026pub fn read_configuration_str(
5027 config_content: String,
5028 file_path: Option<&str>,
5029) -> CuResult<CuConfig> {
5030 read_configuration_str_with_features(config_content, file_path, &[])
5031}
5032
5033pub fn read_configuration_str_with_features(
5035 config_content: String,
5036 file_path: Option<&str>,
5037 active_features: &[&str],
5038) -> CuResult<CuConfig> {
5039 let representation =
5040 resolve_configuration_representation(&config_content, file_path, active_features)?;
5041
5042 config_representation_to_config(representation)
5044}
5045
5046#[cfg(feature = "std")]
5048#[allow(dead_code)]
5049pub fn read_multi_configuration(config_filename: &str) -> CuResult<MultiCopperConfig> {
5050 read_multi_configuration_with_features(config_filename, &[])
5051}
5052
5053#[cfg(feature = "std")]
5055#[allow(dead_code)]
5056pub fn read_multi_configuration_with_features(
5057 config_filename: &str,
5058 active_features: &[&str],
5059) -> CuResult<MultiCopperConfig> {
5060 let config_content = read_to_string(config_filename).map_err(|e| {
5061 CuError::from(format!(
5062 "Failed to read multi-Copper configuration file: {:?}",
5063 config_filename
5064 ))
5065 .add_cause(e.to_string().as_str())
5066 })?;
5067 read_multi_configuration_str_with_features(
5068 config_content,
5069 Some(config_filename),
5070 active_features,
5071 )
5072}
5073
5074#[cfg(feature = "std")]
5076#[allow(dead_code)]
5077pub fn read_multi_configuration_str(
5078 config_content: String,
5079 file_path: Option<&str>,
5080) -> CuResult<MultiCopperConfig> {
5081 read_multi_configuration_str_with_features(config_content, file_path, &[])
5082}
5083
5084#[cfg(feature = "std")]
5086#[allow(dead_code)]
5087pub fn read_multi_configuration_str_with_features(
5088 config_content: String,
5089 file_path: Option<&str>,
5090 active_features: &[&str],
5091) -> CuResult<MultiCopperConfig> {
5092 let representation = parse_multi_config_string(&config_content)?;
5093 validate_multi_config_representation(representation, file_path, active_features)
5094}
5095
5096#[cfg(test)]
5098mod tests {
5099 use super::*;
5100 #[cfg(not(feature = "std"))]
5101 use alloc::vec;
5102 use serde::Deserialize;
5103 #[cfg(feature = "std")]
5104 use std::path::{Path, PathBuf};
5105
5106 #[test]
5107 fn test_plain_serialize() {
5108 let mut config = CuConfig::default();
5109 let graph = config.get_graph_mut(None).unwrap();
5110 let n1 = graph
5111 .add_node(Node::new("test1", "package::Plugin1"))
5112 .unwrap();
5113 let n2 = graph
5114 .add_node(Node::new("test2", "package::Plugin2"))
5115 .unwrap();
5116 graph.connect(n1, n2, "msgpkg::MsgType").unwrap();
5117 let serialized = config.serialize_ron().unwrap();
5118 let deserialized = CuConfig::deserialize_ron(&serialized).unwrap();
5119 let graph = config.graphs.get_graph(None).unwrap();
5120 let deserialized_graph = deserialized.graphs.get_graph(None).unwrap();
5121 assert_eq!(graph.node_count(), deserialized_graph.node_count());
5122 assert_eq!(graph.edge_count(), deserialized_graph.edge_count());
5123 }
5124
5125 #[test]
5126 fn test_serialize_with_params() {
5127 let mut config = CuConfig::default();
5128 let graph = config.get_graph_mut(None).unwrap();
5129 let mut camera = Node::new("copper-camera", "camerapkg::Camera");
5130 camera.set_param::<Value>("resolution-height", 1080.into());
5131 graph.add_node(camera).unwrap();
5132 let serialized = config.serialize_ron().unwrap();
5133 let config = CuConfig::deserialize_ron(&serialized).unwrap();
5134 let deserialized = config.get_graph(None).unwrap();
5135 let resolution = deserialized
5136 .get_node(0)
5137 .unwrap()
5138 .get_param::<i32>("resolution-height")
5139 .expect("resolution-height lookup failed");
5140 assert_eq!(resolution, Some(1080));
5141 }
5142
5143 #[derive(Debug, Deserialize, PartialEq)]
5144 struct InnerSettings {
5145 threshold: u32,
5146 flags: Option<bool>,
5147 }
5148
5149 #[derive(Debug, Deserialize, PartialEq)]
5150 struct SettingsConfig {
5151 gain: f32,
5152 matrix: [[f32; 3]; 3],
5153 inner: InnerSettings,
5154 tags: Vec<String>,
5155 }
5156
5157 #[test]
5158 fn test_component_config_get_value_structured() {
5159 let txt = r#"
5160 (
5161 tasks: [
5162 (
5163 id: "task",
5164 type: "pkg::Task",
5165 config: {
5166 "settings": {
5167 "gain": 1.5,
5168 "matrix": [
5169 [1.0, 0.0, 0.0],
5170 [0.0, 1.0, 0.0],
5171 [0.0, 0.0, 1.0],
5172 ],
5173 "inner": { "threshold": 42, "flags": Some(true) },
5174 "tags": ["alpha", "beta"],
5175 },
5176 },
5177 ),
5178 ],
5179 cnx: [],
5180 )
5181 "#;
5182 let config = CuConfig::deserialize_ron(txt).unwrap();
5183 let graph = config.graphs.get_graph(None).unwrap();
5184 let node = graph.get_node(0).unwrap();
5185 let component = node.get_instance_config().expect("missing config");
5186 let settings = component
5187 .get_value::<SettingsConfig>("settings")
5188 .expect("settings lookup failed")
5189 .expect("missing settings");
5190 let expected = SettingsConfig {
5191 gain: 1.5,
5192 matrix: [[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]],
5193 inner: InnerSettings {
5194 threshold: 42,
5195 flags: Some(true),
5196 },
5197 tags: vec!["alpha".to_string(), "beta".to_string()],
5198 };
5199 assert_eq!(settings, expected);
5200 }
5201
5202 #[test]
5203 fn test_component_config_get_value_scalar_compatibility() {
5204 let txt = r#"
5205 (
5206 tasks: [
5207 (id: "task", type: "pkg::Task", config: { "scalar": 7 }),
5208 ],
5209 cnx: [],
5210 )
5211 "#;
5212 let config = CuConfig::deserialize_ron(txt).unwrap();
5213 let graph = config.graphs.get_graph(None).unwrap();
5214 let node = graph.get_node(0).unwrap();
5215 let component = node.get_instance_config().expect("missing config");
5216 let scalar = component
5217 .get::<u32>("scalar")
5218 .expect("scalar lookup failed");
5219 assert_eq!(scalar, Some(7));
5220 }
5221
5222 #[test]
5223 fn test_component_config_get_value_mixed_usage() {
5224 let txt = r#"
5225 (
5226 tasks: [
5227 (
5228 id: "task",
5229 type: "pkg::Task",
5230 config: {
5231 "scalar": 12,
5232 "settings": {
5233 "gain": 2.5,
5234 "matrix": [
5235 [1.0, 2.0, 3.0],
5236 [4.0, 5.0, 6.0],
5237 [7.0, 8.0, 9.0],
5238 ],
5239 "inner": { "threshold": 7, "flags": None },
5240 "tags": ["gamma"],
5241 },
5242 },
5243 ),
5244 ],
5245 cnx: [],
5246 )
5247 "#;
5248 let config = CuConfig::deserialize_ron(txt).unwrap();
5249 let graph = config.graphs.get_graph(None).unwrap();
5250 let node = graph.get_node(0).unwrap();
5251 let component = node.get_instance_config().expect("missing config");
5252 let scalar = component
5253 .get::<u32>("scalar")
5254 .expect("scalar lookup failed");
5255 let settings = component
5256 .get_value::<SettingsConfig>("settings")
5257 .expect("settings lookup failed");
5258 assert_eq!(scalar, Some(12));
5259 assert!(settings.is_some());
5260 }
5261
5262 #[test]
5263 fn test_component_config_get_value_error_includes_key() {
5264 let txt = r#"
5265 (
5266 tasks: [
5267 (
5268 id: "task",
5269 type: "pkg::Task",
5270 config: { "settings": { "gain": 1.0 } },
5271 ),
5272 ],
5273 cnx: [],
5274 )
5275 "#;
5276 let config = CuConfig::deserialize_ron(txt).unwrap();
5277 let graph = config.graphs.get_graph(None).unwrap();
5278 let node = graph.get_node(0).unwrap();
5279 let component = node.get_instance_config().expect("missing config");
5280 let err = component
5281 .get_value::<u32>("settings")
5282 .expect_err("expected type mismatch");
5283 assert!(err.to_string().contains("settings"));
5284 }
5285
5286 #[test]
5287 fn test_deserialization_error() {
5288 let txt = r#"( tasks: (), cnx: [], monitors: [(type: "ExampleMonitor", )] ) "#;
5290 let err = CuConfig::deserialize_ron(txt).expect_err("expected deserialization error");
5291 assert!(
5292 err.to_string()
5293 .contains("Syntax Error in config: Expected opening `[` at position 1:9-1:10")
5294 );
5295 }
5296
5297 #[test]
5298 fn test_compile_time_constant_defaults_and_normalization() {
5299 let config = read_configuration_str(
5300 r#"(
5301 constants: [
5302 (id: "COUNT", storage: usize, value: 12),
5303 (id: "COUNT", module: "diagnostics", storage: usize, value: 24),
5304 (id: "LENGTH_DEFAULT", quantity: length, value: [0.18, 0.0, 0.31]),
5305 (id: "LENGTH_EXPLICIT", quantity: length, unit: meter, storage: f32,
5306 value: [0.18, 0.0, 0.31]),
5307 (id: "LENGTH_MM", quantity: length, unit: millimeter,
5308 value: [180.0, 0.0, 310.0]),
5309 (id: "ANGLE_DEG", quantity: angle, unit: degree, value: 180.0),
5310 (id: "MASS_DEFAULT", quantity: mass, value: 1.0),
5311 (id: "TEMPERATURE_C", quantity: thermodynamic_temperature,
5312 unit: degree_celsius, storage: f64, value: 20.0),
5313 (id: "CONSTRUCTED", module: "geometry", type: "crate::ConstPair",
5314 expression: "crate::ConstPair::new(crate::constants::COUNT)"),
5315 (id: "CONSTRUCTED_COPY", module: "geometry", type: "crate::ConstPair",
5316 expression: "crate::ConstPair::new(crate::constants::COUNT)"),
5317 (id: "CONSTRUCTED_REWRITTEN", module: "geometry", type: "crate::ConstPair",
5318 expression: "crate::ConstPair::new( crate::constants::COUNT )"),
5319 ],
5320 tasks: [],
5321 cnx: [],
5322 )"#
5323 .to_string(),
5324 None,
5325 )
5326 .unwrap();
5327
5328 assert_eq!(config.constants[0].module_path(), "constants");
5329 assert_eq!(config.constants[0].qualified_id(), "constants::COUNT");
5330 assert_eq!(config.constants[0].storage(), ConstantStorage::Usize);
5331 assert_eq!(config.constants[1].module_path(), "diagnostics");
5332 assert_eq!(config.constants[1].qualified_id(), "diagnostics::COUNT");
5333 let (_, default_length) = config.constants[2].normalized_f32().unwrap();
5334 let (_, explicit_length) = config.constants[3].normalized_f32().unwrap();
5335 let (_, millimeters) = config.constants[4].normalized_f32().unwrap();
5336 assert_eq!(default_length[0].to_bits(), explicit_length[0].to_bits());
5337 assert_eq!(default_length[2].to_bits(), explicit_length[2].to_bits());
5338 assert_eq!(default_length, millimeters);
5339 assert_eq!(
5340 config.constants[2].semantic_fingerprint().unwrap(),
5341 config.constants[3].semantic_fingerprint().unwrap()
5342 );
5343 assert_eq!(
5344 config.constants[2].semantic_fingerprint().unwrap(),
5345 config.constants[4].semantic_fingerprint().unwrap()
5346 );
5347
5348 let (_, angle) = config.constants[5].normalized_f32().unwrap();
5349 assert_eq!(angle[0].to_bits(), core::f32::consts::PI.to_bits());
5350
5351 let mass = &config.constants[6];
5352 assert_eq!(mass.resolved_unit().unwrap().unwrap().name(), "kilogram");
5353 assert_eq!(mass.normalized_f32().unwrap().1, vec![1.0]);
5354
5355 let temperature = config.constants[7].normalized_f64().unwrap().1[0];
5356 assert!((temperature - 293.15).abs() < f64::EPSILON * 4.0);
5357
5358 assert_eq!(
5359 config.constants[8].expression_definition(),
5360 Some((
5361 "crate::ConstPair",
5362 "crate::ConstPair::new(crate::constants::COUNT)"
5363 ))
5364 );
5365 assert_eq!(
5366 config.constants[8].semantic_fingerprint().unwrap(),
5367 config.constants[9].semantic_fingerprint().unwrap()
5368 );
5369 assert_ne!(
5370 config.constants[8].semantic_fingerprint().unwrap(),
5371 config.constants[10].semantic_fingerprint().unwrap()
5372 );
5373
5374 let serialized = config.serialize_ron().unwrap();
5375 let reparsed = CuConfig::deserialize_ron(&serialized).unwrap();
5376 assert_eq!(
5377 reparsed.constants[8].expression_definition(),
5378 config.constants[8].expression_definition()
5379 );
5380 assert_eq!(
5381 reparsed.constants[8].semantic_fingerprint().unwrap(),
5382 config.constants[8].semantic_fingerprint().unwrap()
5383 );
5384 }
5385
5386 #[test]
5387 fn test_compile_time_constant_rejects_invalid_definition_shapes() {
5388 let cases = [
5389 (
5390 r#"(id: "BAD", type: "crate::Pair")"#,
5391 "declares 'type' without 'expression'",
5392 ),
5393 (
5394 r#"(id: "BAD", expression: "crate::Pair::new()")"#,
5395 "declares 'expression' without 'type'",
5396 ),
5397 (
5398 r#"(id: "BAD", value: 1, type: "u32", expression: "1")"#,
5399 "cannot combine numeric 'value' with 'type' or 'expression'",
5400 ),
5401 (
5402 r#"(id: "BAD", storage: f32, type: "u32", expression: "1")"#,
5403 "cannot combine 'type' and 'expression' with numeric 'storage', 'quantity', or 'unit'",
5404 ),
5405 (
5406 r#"(id: "BAD")"#,
5407 "must declare either numeric 'value' or both 'type' and 'expression'",
5408 ),
5409 ];
5410
5411 for (constant, expected) in cases {
5412 let source = format!("(constants: [{constant}], tasks: [], cnx: [])");
5413 let error = read_configuration_str(source, None)
5414 .expect_err("invalid constant definition shape must fail");
5415 assert!(
5416 error.to_string().contains(expected),
5417 "unexpected error: {error}"
5418 );
5419 }
5420 }
5421
5422 #[test]
5423 fn test_compile_time_constant_rejects_duplicate_qualified_id() {
5424 let error = read_configuration_str(
5425 r#"(
5426 constants: [
5427 (id: "COUNT", module: "diagnostics", value: 1),
5428 (id: "COUNT", module: "diagnostics", value: 2),
5429 ],
5430 tasks: [],
5431 cnx: [],
5432 )"#
5433 .to_string(),
5434 None,
5435 )
5436 .expect_err("duplicate qualified constant id must fail");
5437 assert!(
5438 error
5439 .to_string()
5440 .contains("Duplicate constant 'diagnostics::COUNT'")
5441 );
5442 }
5443
5444 #[test]
5445 fn test_compile_time_constant_rejects_incompatible_unit() {
5446 let error = read_configuration_str(
5447 r#"(
5448 constants: [(id: "BAD", quantity: length, unit: degree, value: 1.0)],
5449 tasks: [],
5450 cnx: [],
5451 )"#
5452 .to_string(),
5453 None,
5454 )
5455 .expect_err("length in degrees must fail");
5456 assert!(
5457 error
5458 .to_string()
5459 .contains("unit 'degree' is not compatible with quantity 'length'")
5460 );
5461 }
5462
5463 #[test]
5464 fn test_missions() {
5465 let txt = r#"( missions: [ (id: "data_collection"), (id: "autonomous")])"#;
5466 let config = CuConfig::deserialize_ron(txt).unwrap();
5467 let graph = config.graphs.get_graph(Some("data_collection")).unwrap();
5468 assert!(graph.node_count() == 0);
5469 let graph = config.graphs.get_graph(Some("autonomous")).unwrap();
5470 assert!(graph.node_count() == 0);
5471 }
5472
5473 #[test]
5474 fn test_monitor_plural_syntax() {
5475 let txt = r#"( tasks: [], cnx: [], monitors: [(type: "ExampleMonitor", )] ) "#;
5476 let config = CuConfig::deserialize_ron(txt).unwrap();
5477 assert_eq!(config.get_monitor_config().unwrap().type_, "ExampleMonitor");
5478
5479 let txt = r#"( tasks: [], cnx: [], monitors: [(type: "ExampleMonitor", config: { "toto": 4, } )] ) "#;
5480 let config = CuConfig::deserialize_ron(txt).unwrap();
5481 assert_eq!(
5482 config
5483 .get_monitor_config()
5484 .unwrap()
5485 .config
5486 .as_ref()
5487 .unwrap()
5488 .0["toto"]
5489 .0,
5490 4u8.into()
5491 );
5492 }
5493
5494 #[test]
5495 fn test_monitor_singular_syntax() {
5496 let txt = r#"( tasks: [], cnx: [], monitor: (type: "ExampleMonitor", config: { "toto": 4, } ) ) "#;
5497 let config = CuConfig::deserialize_ron(txt).unwrap();
5498 assert_eq!(config.get_monitor_configs().len(), 1);
5499 assert_eq!(config.get_monitor_config().unwrap().type_, "ExampleMonitor");
5500 assert_eq!(
5501 config
5502 .get_monitor_config()
5503 .unwrap()
5504 .config
5505 .as_ref()
5506 .unwrap()
5507 .0["toto"]
5508 .0,
5509 4u8.into()
5510 );
5511 }
5512
5513 #[test]
5514 #[cfg(feature = "std")]
5515 fn test_render_topology_multi_input_ports() {
5516 let mut config = CuConfig::default();
5517 let graph = config.get_graph_mut(None).unwrap();
5518 let src1 = graph.add_node(Node::new("src1", "tasks::Source1")).unwrap();
5519 let src2 = graph.add_node(Node::new("src2", "tasks::Source2")).unwrap();
5520 let dst = graph.add_node(Node::new("dst", "tasks::Dst")).unwrap();
5521 graph.connect(src1, dst, "msg::A").unwrap();
5522 graph.connect(src2, dst, "msg::B").unwrap();
5523
5524 let topology = build_render_topology(graph, &[]);
5525 let dst_node = topology
5526 .nodes
5527 .iter()
5528 .find(|node| node.id == "dst")
5529 .expect("missing dst node");
5530 assert_eq!(dst_node.inputs.len(), 2);
5531
5532 let mut dst_ports: Vec<_> = topology
5533 .connections
5534 .iter()
5535 .filter(|cnx| cnx.dst == "dst")
5536 .map(|cnx| cnx.dst_port.as_deref().expect("missing dst port"))
5537 .collect();
5538 dst_ports.sort();
5539 assert_eq!(dst_ports, vec!["in.0", "in.1"]);
5540 }
5541
5542 #[test]
5543 fn test_logging_parameters() {
5544 let txt = r#"( tasks: [], cnx: [], logging: ( slab_size_mib: 1024, section_size_mib: 100, enable_task_logging: false ),) "#;
5546
5547 let config = CuConfig::deserialize_ron(txt).unwrap();
5548 assert!(config.logging.is_some());
5549 let logging_config = config.logging.unwrap();
5550 assert_eq!(logging_config.slab_size_mib.unwrap(), 1024);
5551 assert_eq!(logging_config.section_size_mib.unwrap(), 100);
5552 assert!(!logging_config.enable_task_logging);
5553
5554 let txt =
5556 r#"( tasks: [], cnx: [], logging: ( slab_size_mib: 1024, section_size_mib: 100, ),) "#;
5557 let config = CuConfig::deserialize_ron(txt).unwrap();
5558 assert!(config.logging.is_some());
5559 let logging_config = config.logging.unwrap();
5560 assert_eq!(logging_config.slab_size_mib.unwrap(), 1024);
5561 assert_eq!(logging_config.section_size_mib.unwrap(), 100);
5562 assert!(logging_config.enable_task_logging);
5563 }
5564
5565 #[test]
5566 fn test_node_logging_handle_content_round_trips() {
5567 let txt = r#"(
5569 tasks: [
5570 (id: "cam", type: "pkg::Cam", kind: source, logging: (handle_content: touched_only)),
5571 (id: "noop", type: "pkg::Noop", kind: sink),
5572 ],
5573 cnx: [
5574 (src: "cam", dst: "noop", msg: "pkg::Frame"),
5575 ],
5576 )"#;
5577
5578 let config = CuConfig::deserialize_ron(txt).unwrap();
5579 let cam = config.find_task_node(None, "cam").unwrap();
5580 assert_eq!(cam.handle_content_policy(), HandleContent::TouchedOnly);
5581
5582 let noop = config.find_task_node(None, "noop").unwrap();
5584 assert_eq!(noop.handle_content_policy(), HandleContent::All);
5585
5586 let reserialized = config.serialize_ron().unwrap();
5588 let reparsed = CuConfig::deserialize_ron(&reserialized).unwrap();
5589 let cam2 = reparsed.find_task_node(None, "cam").unwrap();
5590 assert_eq!(cam2.handle_content_policy(), HandleContent::TouchedOnly);
5591 }
5592
5593 #[test]
5594 fn test_node_logging_handle_content_all_variants_parse() {
5595 for (value, expected) in [
5596 ("all", HandleContent::All),
5597 ("touched_only", HandleContent::TouchedOnly),
5598 ("none", HandleContent::None),
5599 ] {
5600 let txt = format!(
5601 r#"(
5602 tasks: [(id: "s", type: "pkg::T", kind: source, logging: (handle_content: {value}))],
5603 cnx: [(src: "s", dst: "__nc__", msg: "pkg::M")],
5604 )"#
5605 );
5606 let config = CuConfig::deserialize_ron(&txt).unwrap();
5607 assert_eq!(
5608 config
5609 .find_task_node(None, "s")
5610 .unwrap()
5611 .handle_content_policy(),
5612 expected,
5613 "policy mismatch for `{value}`"
5614 );
5615 }
5616 }
5617
5618 #[test]
5619 fn test_bridge_parsing() {
5620 let txt = r#"
5621 (
5622 tasks: [
5623 (id: "dst", type: "tasks::Destination"),
5624 (id: "src", type: "tasks::Source"),
5625 ],
5626 bridges: [
5627 (
5628 id: "radio",
5629 type: "tasks::SerialBridge",
5630 config: { "path": "/dev/ttyACM0", "baud": 921600 },
5631 channels: [
5632 Rx ( id: "status", route: "sys/status" ),
5633 Tx ( id: "motor", route: "motor/cmd" ),
5634 ],
5635 ),
5636 ],
5637 cnx: [
5638 (src: "radio/status", dst: "dst", msg: "mymsgs::Status"),
5639 (src: "src", dst: "radio/motor", msg: "mymsgs::MotorCmd"),
5640 ],
5641 )
5642 "#;
5643
5644 let config = CuConfig::deserialize_ron(txt).unwrap();
5645 assert_eq!(config.bridges.len(), 1);
5646 let bridge = &config.bridges[0];
5647 assert_eq!(bridge.id, "radio");
5648 assert_eq!(bridge.channels.len(), 2);
5649 match &bridge.channels[0] {
5650 BridgeChannelConfigRepresentation::Rx { id, route, .. } => {
5651 assert_eq!(id, "status");
5652 assert_eq!(route.as_deref(), Some("sys/status"));
5653 }
5654 _ => panic!("expected Rx channel"),
5655 }
5656 match &bridge.channels[1] {
5657 BridgeChannelConfigRepresentation::Tx { id, route, .. } => {
5658 assert_eq!(id, "motor");
5659 assert_eq!(route.as_deref(), Some("motor/cmd"));
5660 }
5661 _ => panic!("expected Tx channel"),
5662 }
5663 let graph = config.graphs.get_graph(None).unwrap();
5664 let bridge_id = graph
5665 .get_node_id_by_name("radio")
5666 .expect("bridge node missing");
5667 let bridge_node = graph.get_node(bridge_id).unwrap();
5668 assert_eq!(bridge_node.get_flavor(), Flavor::Bridge);
5669
5670 let mut edges = Vec::new();
5672 for edge_idx in graph.0.edge_indices() {
5673 edges.push(graph.0[edge_idx].clone());
5674 }
5675 assert_eq!(edges.len(), 2);
5676 let status_edge = edges
5677 .iter()
5678 .find(|e| e.dst == "dst")
5679 .expect("status edge missing");
5680 assert_eq!(status_edge.src_channel.as_deref(), Some("status"));
5681 assert!(status_edge.dst_channel.is_none());
5682 let motor_edge = edges
5683 .iter()
5684 .find(|e| e.dst_channel.is_some())
5685 .expect("motor edge missing");
5686 assert_eq!(motor_edge.dst_channel.as_deref(), Some("motor"));
5687 }
5688
5689 #[test]
5690 fn test_bridge_roundtrip() {
5691 let mut config = CuConfig::default();
5692 let mut bridge_config = ComponentConfig::default();
5693 bridge_config.set("port", "/dev/ttyACM0".to_string());
5694 config.bridges.push(BridgeConfig {
5695 id: "radio".to_string(),
5696 type_: "tasks::SerialBridge".to_string(),
5697 config: Some(bridge_config),
5698 resources: None,
5699 missions: None,
5700 run_in_sim: None,
5701 channels: vec![
5702 BridgeChannelConfigRepresentation::Rx {
5703 id: "status".to_string(),
5704 route: Some("sys/status".to_string()),
5705 config: None,
5706 },
5707 BridgeChannelConfigRepresentation::Tx {
5708 id: "motor".to_string(),
5709 route: Some("motor/cmd".to_string()),
5710 config: None,
5711 },
5712 ],
5713 });
5714
5715 let serialized = config.serialize_ron().unwrap();
5716 assert!(
5717 serialized.contains("bridges"),
5718 "bridges section missing from serialized config"
5719 );
5720 let deserialized = CuConfig::deserialize_ron(&serialized).unwrap();
5721 assert_eq!(deserialized.bridges.len(), 1);
5722 let bridge = &deserialized.bridges[0];
5723 assert!(bridge.is_run_in_sim());
5724 assert_eq!(bridge.channels.len(), 2);
5725 assert!(matches!(
5726 bridge.channels[0],
5727 BridgeChannelConfigRepresentation::Rx { .. }
5728 ));
5729 assert!(matches!(
5730 bridge.channels[1],
5731 BridgeChannelConfigRepresentation::Tx { .. }
5732 ));
5733 }
5734
5735 #[test]
5736 fn test_resource_parsing() {
5737 let txt = r#"
5738 (
5739 resources: [
5740 (
5741 id: "fc",
5742 provider: "copper_board_px4::Px4Bundle",
5743 config: { "baud": 921600 },
5744 missions: ["m1"],
5745 ),
5746 (
5747 id: "misc",
5748 provider: "cu29_runtime::StdClockBundle",
5749 ),
5750 ],
5751 )
5752 "#;
5753
5754 let config = CuConfig::deserialize_ron(txt).unwrap();
5755 assert_eq!(config.resources.len(), 2);
5756 let fc = &config.resources[0];
5757 assert_eq!(fc.id, "fc");
5758 assert_eq!(fc.provider, "copper_board_px4::Px4Bundle");
5759 assert_eq!(fc.missions.as_deref(), Some(&["m1".to_string()][..]));
5760 let baud: u32 = fc
5761 .config
5762 .as_ref()
5763 .expect("missing config")
5764 .get::<u32>("baud")
5765 .expect("baud lookup failed")
5766 .expect("missing baud");
5767 assert_eq!(baud, 921_600);
5768 let misc = &config.resources[1];
5769 assert_eq!(misc.id, "misc");
5770 assert_eq!(misc.provider, "cu29_runtime::StdClockBundle");
5771 assert!(misc.config.is_none());
5772 }
5773
5774 #[test]
5775 fn test_resource_roundtrip() {
5776 let mut config = CuConfig::default();
5777 let mut bundle_cfg = ComponentConfig::default();
5778 bundle_cfg.set("path", "/dev/ttyACM0".to_string());
5779 config.resources.push(ResourceBundleConfig {
5780 id: "fc".to_string(),
5781 provider: "copper_board_px4::Px4Bundle".to_string(),
5782 config: Some(bundle_cfg),
5783 missions: Some(vec!["m1".to_string()]),
5784 });
5785
5786 let serialized = config.serialize_ron().unwrap();
5787 let deserialized = CuConfig::deserialize_ron(&serialized).unwrap();
5788 assert_eq!(deserialized.resources.len(), 1);
5789 let res = &deserialized.resources[0];
5790 assert_eq!(res.id, "fc");
5791 assert_eq!(res.provider, "copper_board_px4::Px4Bundle");
5792 assert_eq!(res.missions.as_deref(), Some(&["m1".to_string()][..]));
5793 let path: String = res
5794 .config
5795 .as_ref()
5796 .expect("missing config")
5797 .get::<String>("path")
5798 .expect("path lookup failed")
5799 .expect("missing path");
5800 assert_eq!(path, "/dev/ttyACM0");
5801 }
5802
5803 #[test]
5804 fn test_bridge_channel_config() {
5805 let txt = r#"
5806 (
5807 tasks: [],
5808 bridges: [
5809 (
5810 id: "radio",
5811 type: "tasks::SerialBridge",
5812 channels: [
5813 Rx ( id: "status", route: "sys/status", config: { "filter": "fast" } ),
5814 Tx ( id: "imu", route: "telemetry/imu", config: { "rate": 100 } ),
5815 ],
5816 ),
5817 ],
5818 cnx: [],
5819 )
5820 "#;
5821
5822 let config = CuConfig::deserialize_ron(txt).unwrap();
5823 let bridge = &config.bridges[0];
5824 match &bridge.channels[0] {
5825 BridgeChannelConfigRepresentation::Rx {
5826 config: Some(cfg), ..
5827 } => {
5828 let val = cfg
5829 .get::<String>("filter")
5830 .expect("filter lookup failed")
5831 .expect("filter missing");
5832 assert_eq!(val, "fast");
5833 }
5834 _ => panic!("expected Rx channel with config"),
5835 }
5836 match &bridge.channels[1] {
5837 BridgeChannelConfigRepresentation::Tx {
5838 config: Some(cfg), ..
5839 } => {
5840 let rate = cfg
5841 .get::<i32>("rate")
5842 .expect("rate lookup failed")
5843 .expect("rate missing");
5844 assert_eq!(rate, 100);
5845 }
5846 _ => panic!("expected Tx channel with config"),
5847 }
5848 }
5849
5850 #[test]
5851 fn test_task_resources_roundtrip() {
5852 let txt = r#"
5853 (
5854 tasks: [
5855 (
5856 id: "imu",
5857 type: "tasks::ImuDriver",
5858 resources: { "bus": "fc.spi_1", "irq": "fc.gpio_imu" },
5859 ),
5860 ],
5861 cnx: [],
5862 )
5863 "#;
5864
5865 let config = CuConfig::deserialize_ron(txt).unwrap();
5866 let graph = config.graphs.get_graph(None).unwrap();
5867 let node = graph.get_node(0).expect("missing task node");
5868 let resources = node.get_resources().expect("missing resources map");
5869 assert_eq!(resources.get("bus").map(String::as_str), Some("fc.spi_1"));
5870 assert_eq!(
5871 resources.get("irq").map(String::as_str),
5872 Some("fc.gpio_imu")
5873 );
5874
5875 let serialized = config.serialize_ron().unwrap();
5876 let deserialized = CuConfig::deserialize_ron(&serialized).unwrap();
5877 let graph = deserialized.graphs.get_graph(None).unwrap();
5878 let node = graph.get_node(0).expect("missing task node");
5879 let resources = node
5880 .get_resources()
5881 .expect("missing resources map after roundtrip");
5882 assert_eq!(resources.get("bus").map(String::as_str), Some("fc.spi_1"));
5883 assert_eq!(
5884 resources.get("irq").map(String::as_str),
5885 Some("fc.gpio_imu")
5886 );
5887 }
5888
5889 #[test]
5890 fn test_bridge_resources_preserved() {
5891 let mut config = CuConfig::default();
5892 config.resources.push(ResourceBundleConfig {
5893 id: "fc".to_string(),
5894 provider: "board::Bundle".to_string(),
5895 config: None,
5896 missions: None,
5897 });
5898 let bridge_resources = HashMap::from([("serial".to_string(), "fc.serial0".to_string())]);
5899 config.bridges.push(BridgeConfig {
5900 id: "radio".to_string(),
5901 type_: "tasks::SerialBridge".to_string(),
5902 config: None,
5903 resources: Some(bridge_resources),
5904 missions: None,
5905 run_in_sim: None,
5906 channels: vec![BridgeChannelConfigRepresentation::Tx {
5907 id: "uplink".to_string(),
5908 route: None,
5909 config: None,
5910 }],
5911 });
5912
5913 let serialized = config.serialize_ron().unwrap();
5914 let deserialized = CuConfig::deserialize_ron(&serialized).unwrap();
5915 let graph = deserialized.graphs.get_graph(None).expect("missing graph");
5916 let bridge_id = graph
5917 .get_node_id_by_name("radio")
5918 .expect("bridge node missing");
5919 let node = graph.get_node(bridge_id).expect("missing bridge node");
5920 let resources = node
5921 .get_resources()
5922 .expect("bridge resources were not preserved");
5923 assert_eq!(
5924 resources.get("serial").map(String::as_str),
5925 Some("fc.serial0")
5926 );
5927 }
5928
5929 #[test]
5930 fn test_demo_config_parses() {
5931 let txt = r#"(
5932 resources: [
5933 (
5934 id: "fc",
5935 provider: "crate::resources::RadioBundle",
5936 ),
5937 ],
5938 tasks: [
5939 (id: "thr", type: "tasks::ThrottleControl"),
5940 (id: "tele0", type: "tasks::TelemetrySink0"),
5941 (id: "tele1", type: "tasks::TelemetrySink1"),
5942 (id: "tele2", type: "tasks::TelemetrySink2"),
5943 (id: "tele3", type: "tasks::TelemetrySink3"),
5944 ],
5945 bridges: [
5946 ( id: "crsf",
5947 type: "cu_crsf::CrsfBridge<SerialResource, SerialPortError>",
5948 resources: { "serial": "fc.serial" },
5949 channels: [
5950 Rx ( id: "rc_rx" ), // receiving RC Channels
5951 Tx ( id: "lq_tx" ), // Sending LineQuality back
5952 ],
5953 ),
5954 (
5955 id: "bdshot",
5956 type: "cu_bdshot::RpBdshotBridge",
5957 channels: [
5958 Tx ( id: "esc0_tx" ),
5959 Tx ( id: "esc1_tx" ),
5960 Tx ( id: "esc2_tx" ),
5961 Tx ( id: "esc3_tx" ),
5962 Rx ( id: "esc0_rx" ),
5963 Rx ( id: "esc1_rx" ),
5964 Rx ( id: "esc2_rx" ),
5965 Rx ( id: "esc3_rx" ),
5966 ],
5967 ),
5968 ],
5969 cnx: [
5970 (src: "crsf/rc_rx", dst: "thr", msg: "cu_crsf::messages::RcChannelsPayload"),
5971 (src: "thr", dst: "bdshot/esc0_tx", msg: "cu_bdshot::EscCommand"),
5972 (src: "thr", dst: "bdshot/esc1_tx", msg: "cu_bdshot::EscCommand"),
5973 (src: "thr", dst: "bdshot/esc2_tx", msg: "cu_bdshot::EscCommand"),
5974 (src: "thr", dst: "bdshot/esc3_tx", msg: "cu_bdshot::EscCommand"),
5975 (src: "bdshot/esc0_rx", dst: "tele0", msg: "cu_bdshot::EscTelemetry"),
5976 (src: "bdshot/esc1_rx", dst: "tele1", msg: "cu_bdshot::EscTelemetry"),
5977 (src: "bdshot/esc2_rx", dst: "tele2", msg: "cu_bdshot::EscTelemetry"),
5978 (src: "bdshot/esc3_rx", dst: "tele3", msg: "cu_bdshot::EscTelemetry"),
5979 ],
5980)"#;
5981 let config = CuConfig::deserialize_ron(txt).unwrap();
5982 assert_eq!(config.resources.len(), 1);
5983 assert_eq!(config.bridges.len(), 2);
5984 }
5985
5986 #[test]
5987 fn test_bridge_tx_cannot_be_source() {
5988 let txt = r#"
5989 (
5990 tasks: [
5991 (id: "dst", type: "tasks::Destination"),
5992 ],
5993 bridges: [
5994 (
5995 id: "radio",
5996 type: "tasks::SerialBridge",
5997 channels: [
5998 Tx ( id: "motor", route: "motor/cmd" ),
5999 ],
6000 ),
6001 ],
6002 cnx: [
6003 (src: "radio/motor", dst: "dst", msg: "mymsgs::MotorCmd"),
6004 ],
6005 )
6006 "#;
6007
6008 let err = CuConfig::deserialize_ron(txt).expect_err("expected bridge source error");
6009 assert!(
6010 err.to_string()
6011 .contains("channel 'motor' is Tx and cannot act as a source")
6012 );
6013 }
6014
6015 #[test]
6016 fn test_bridge_rx_cannot_be_destination() {
6017 let txt = r#"
6018 (
6019 tasks: [
6020 (id: "src", type: "tasks::Source"),
6021 ],
6022 bridges: [
6023 (
6024 id: "radio",
6025 type: "tasks::SerialBridge",
6026 channels: [
6027 Rx ( id: "status", route: "sys/status" ),
6028 ],
6029 ),
6030 ],
6031 cnx: [
6032 (src: "src", dst: "radio/status", msg: "mymsgs::Status"),
6033 ],
6034 )
6035 "#;
6036
6037 let err = CuConfig::deserialize_ron(txt).expect_err("expected bridge destination error");
6038 assert!(
6039 err.to_string()
6040 .contains("channel 'status' is Rx and cannot act as a destination")
6041 );
6042 }
6043
6044 #[test]
6045 fn test_validate_logging_config() {
6046 let txt =
6048 r#"( tasks: [], cnx: [], logging: ( slab_size_mib: 1024, section_size_mib: 100 ) )"#;
6049 let config = CuConfig::deserialize_ron(txt).unwrap();
6050 assert!(config.validate_logging_config().is_ok());
6051
6052 let txt =
6054 r#"( tasks: [], cnx: [], logging: ( slab_size_mib: 100, section_size_mib: 1024 ) )"#;
6055 let config = CuConfig::deserialize_ron(txt).unwrap();
6056 assert!(config.validate_logging_config().is_err());
6057 }
6058
6059 #[test]
6061 fn test_deserialization_edge_id_assignment() {
6062 let txt = r#"(
6065 tasks: [(id: "src1", type: "a"), (id: "src2", type: "b"), (id: "sink", type: "c")],
6066 cnx: [(src: "src2", dst: "sink", msg: "msg1"), (src: "src1", dst: "sink", msg: "msg2")]
6067 )"#;
6068 let config = CuConfig::deserialize_ron(txt).unwrap();
6069 let graph = config.graphs.get_graph(None).unwrap();
6070 assert!(config.validate_logging_config().is_ok());
6071
6072 let src1_id = 0;
6074 assert_eq!(graph.get_node(src1_id).unwrap().id, "src1");
6075 let src2_id = 1;
6076 assert_eq!(graph.get_node(src2_id).unwrap().id, "src2");
6077
6078 let src1_edge_id = *graph.get_src_edges(src1_id).unwrap().first().unwrap();
6081 assert_eq!(src1_edge_id, 1);
6082 let src2_edge_id = *graph.get_src_edges(src2_id).unwrap().first().unwrap();
6083 assert_eq!(src2_edge_id, 0);
6084 }
6085
6086 #[test]
6087 fn test_simple_missions() {
6088 let txt = r#"(
6090 missions: [ (id: "m1"),
6091 (id: "m2"),
6092 ],
6093 tasks: [(id: "src1", type: "a", missions: ["m1"]),
6094 (id: "src2", type: "b", missions: ["m2"]),
6095 (id: "sink", type: "c")],
6096
6097 cnx: [
6098 (src: "src1", dst: "sink", msg: "u32", missions: ["m1"]),
6099 (src: "src2", dst: "sink", msg: "u32", missions: ["m2"]),
6100 ],
6101 )
6102 "#;
6103
6104 let config = CuConfig::deserialize_ron(txt).unwrap();
6105 let m1_graph = config.graphs.get_graph(Some("m1")).unwrap();
6106 assert_eq!(m1_graph.edge_count(), 1);
6107 assert_eq!(m1_graph.node_count(), 2);
6108 let index = 0;
6109 let cnx = m1_graph.get_edge_weight(index).unwrap();
6110
6111 assert_eq!(cnx.src, "src1");
6112 assert_eq!(cnx.dst, "sink");
6113 assert_eq!(cnx.msg, "u32");
6114 assert_eq!(cnx.missions, Some(vec!["m1".to_string()]));
6115
6116 let m2_graph = config.graphs.get_graph(Some("m2")).unwrap();
6117 assert_eq!(m2_graph.edge_count(), 1);
6118 assert_eq!(m2_graph.node_count(), 2);
6119 let index = 0;
6120 let cnx = m2_graph.get_edge_weight(index).unwrap();
6121 assert_eq!(cnx.src, "src2");
6122 assert_eq!(cnx.dst, "sink");
6123 assert_eq!(cnx.msg, "u32");
6124 assert_eq!(cnx.missions, Some(vec!["m2".to_string()]));
6125 }
6126 #[test]
6127 fn test_mission_serde() {
6128 let txt = r#"(
6130 missions: [ (id: "m1"),
6131 (id: "m2"),
6132 ],
6133 tasks: [(id: "src1", type: "a", missions: ["m1"]),
6134 (id: "src2", type: "b", missions: ["m2"]),
6135 (id: "sink", type: "c")],
6136
6137 cnx: [
6138 (src: "src1", dst: "sink", msg: "u32", missions: ["m1"]),
6139 (src: "src2", dst: "sink", msg: "u32", missions: ["m2"]),
6140 ],
6141 )
6142 "#;
6143
6144 let config = CuConfig::deserialize_ron(txt).unwrap();
6145 let serialized = config.serialize_ron().unwrap();
6146 let deserialized = CuConfig::deserialize_ron(&serialized).unwrap();
6147 let m1_graph = deserialized.graphs.get_graph(Some("m1")).unwrap();
6148 assert_eq!(m1_graph.edge_count(), 1);
6149 assert_eq!(m1_graph.node_count(), 2);
6150 let index = 0;
6151 let cnx = m1_graph.get_edge_weight(index).unwrap();
6152 assert_eq!(cnx.src, "src1");
6153 assert_eq!(cnx.dst, "sink");
6154 assert_eq!(cnx.msg, "u32");
6155 assert_eq!(cnx.missions, Some(vec!["m1".to_string()]));
6156 }
6157
6158 #[test]
6159 fn test_mission_scoped_nc_connection_survives_serialize_roundtrip() {
6160 let txt = r#"(
6161 missions: [(id: "m1"), (id: "m2")],
6162 tasks: [
6163 (id: "src_m1", type: "a", missions: ["m1"]),
6164 (id: "src_m2", type: "b", missions: ["m2"]),
6165 ],
6166 cnx: [
6167 (src: "src_m1", dst: "__nc__", msg: "msg::A", missions: ["m1"]),
6168 (src: "src_m2", dst: "__nc__", msg: "msg::B", missions: ["m2"]),
6169 ]
6170 )"#;
6171
6172 let config = CuConfig::deserialize_ron(txt).unwrap();
6173 let serialized = config.serialize_ron().unwrap();
6174 let deserialized = CuConfig::deserialize_ron(&serialized).unwrap();
6175
6176 let m1_graph = deserialized.graphs.get_graph(Some("m1")).unwrap();
6177 let src_m1_id = m1_graph.get_node_id_by_name("src_m1").unwrap();
6178 let src_m1 = m1_graph.get_node(src_m1_id).unwrap();
6179 assert_eq!(src_m1.nc_outputs(), &["msg::A".to_string()]);
6180
6181 let m2_graph = deserialized.graphs.get_graph(Some("m2")).unwrap();
6182 let src_m2_id = m2_graph.get_node_id_by_name("src_m2").unwrap();
6183 let src_m2 = m2_graph.get_node(src_m2_id).unwrap();
6184 assert_eq!(src_m2.nc_outputs(), &["msg::B".to_string()]);
6185 }
6186
6187 #[test]
6188 fn test_keyframe_interval() {
6189 let txt = r#"(
6192 tasks: [(id: "src1", type: "a"), (id: "src2", type: "b"), (id: "sink", type: "c")],
6193 cnx: [(src: "src2", dst: "sink", msg: "msg1"), (src: "src1", dst: "sink", msg: "msg2")],
6194 logging: ( keyframe_interval: 314 )
6195 )"#;
6196 let config = CuConfig::deserialize_ron(txt).unwrap();
6197 let logging_config = config.logging.unwrap();
6198 assert_eq!(logging_config.keyframe_interval.unwrap(), 314);
6199 }
6200
6201 #[test]
6202 fn test_default_keyframe_interval() {
6203 let txt = r#"(
6206 tasks: [(id: "src1", type: "a"), (id: "src2", type: "b"), (id: "sink", type: "c")],
6207 cnx: [(src: "src2", dst: "sink", msg: "msg1"), (src: "src1", dst: "sink", msg: "msg2")],
6208 logging: ( slab_size_mib: 200, section_size_mib: 1024, )
6209 )"#;
6210 let config = CuConfig::deserialize_ron(txt).unwrap();
6211 let logging_config = config.logging.unwrap();
6212 assert_eq!(logging_config.keyframe_interval.unwrap(), 100);
6213 }
6214
6215 #[test]
6216 fn test_task_kind_roundtrip_and_alias() {
6217 let txt = r#"(
6218 tasks: [
6219 (id: "src", type: "a", kind: source),
6220 (id: "regular", type: "b", kind: regular),
6221 (id: "sink", type: "c", kind: sink),
6222 ],
6223 cnx: [
6224 (src: "src", dst: "regular", msg: "msg::A"),
6225 (src: "regular", dst: "sink", msg: "msg::B"),
6226 ]
6227 )"#;
6228
6229 let config = CuConfig::deserialize_ron(txt).unwrap();
6230 let graph = config.get_graph(None).unwrap();
6231
6232 assert_eq!(
6233 graph
6234 .get_node(graph.get_node_id_by_name("src").unwrap())
6235 .unwrap()
6236 .get_declared_task_kind(),
6237 Some(TaskKind::Source)
6238 );
6239 assert_eq!(
6240 graph
6241 .get_node(graph.get_node_id_by_name("regular").unwrap())
6242 .unwrap()
6243 .get_declared_task_kind(),
6244 Some(TaskKind::Regular)
6245 );
6246 assert_eq!(
6247 graph
6248 .get_node(graph.get_node_id_by_name("sink").unwrap())
6249 .unwrap()
6250 .get_declared_task_kind(),
6251 Some(TaskKind::Sink)
6252 );
6253
6254 let serialized = config.serialize_ron().unwrap();
6255 assert!(serialized.contains("kind: source"));
6256 assert!(serialized.contains("kind: task"));
6257 assert!(serialized.contains("kind: sink"));
6258 }
6259
6260 #[test]
6261 fn test_resolve_task_kind_uses_nc_outputs_for_regular_tasks() {
6262 let txt = r#"(
6263 tasks: [
6264 (id: "src", type: "a"),
6265 (id: "regular", type: "b"),
6266 ],
6267 cnx: [
6268 (src: "src", dst: "regular", msg: "msg::A"),
6269 (src: "regular", dst: "__nc__", msg: "msg::B"),
6270 ]
6271 )"#;
6272
6273 let config = CuConfig::deserialize_ron(txt).unwrap();
6274 let graph = config.get_graph(None).unwrap();
6275 let regular_id = graph.get_node_id_by_name("regular").unwrap();
6276
6277 assert_eq!(
6278 resolve_task_kind_for_id(graph, regular_id).unwrap(),
6279 TaskKind::Regular
6280 );
6281 }
6282
6283 #[test]
6284 fn test_resolve_task_kind_rejects_isolated_task_without_kind() {
6285 let txt = r#"(
6286 tasks: [
6287 (id: "lonely", type: "a"),
6288 ],
6289 cnx: []
6290 )"#;
6291
6292 let config = CuConfig::deserialize_ron(txt).unwrap();
6293 let graph = config.get_graph(None).unwrap();
6294 let lonely_id = graph.get_node_id_by_name("lonely").unwrap();
6295
6296 let err = resolve_task_kind_for_id(graph, lonely_id).expect_err("expected task kind error");
6297 assert!(
6298 err.to_string()
6299 .contains("cannot infer whether it is a source, task, or sink"),
6300 "unexpected error: {err}"
6301 );
6302 }
6303
6304 #[test]
6305 fn test_resolve_explicit_source_kind_allows_missing_declared_outputs() {
6306 let txt = r#"(
6307 tasks: [
6308 (id: "src", type: "a", kind: source),
6309 ],
6310 cnx: []
6311 )"#;
6312
6313 let config = CuConfig::deserialize_ron(txt).unwrap();
6314 let graph = config.get_graph(None).unwrap();
6315 let src_id = graph.get_node_id_by_name("src").unwrap();
6316
6317 assert_eq!(
6318 resolve_task_kind_for_id(graph, src_id).unwrap(),
6319 TaskKind::Source
6320 );
6321 }
6322
6323 #[test]
6324 fn test_resolve_explicit_regular_kind_allows_missing_declared_outputs() {
6325 let txt = r#"(
6326 tasks: [
6327 (id: "src", type: "a"),
6328 (id: "regular", type: "b", kind: task),
6329 ],
6330 cnx: [
6331 (src: "src", dst: "regular", msg: "msg::A"),
6332 ]
6333 )"#;
6334
6335 let config = CuConfig::deserialize_ron(txt).unwrap();
6336 let graph = config.get_graph(None).unwrap();
6337 let regular_id = graph.get_node_id_by_name("regular").unwrap();
6338
6339 assert_eq!(
6340 resolve_task_kind_for_id(graph, regular_id).unwrap(),
6341 TaskKind::Regular
6342 );
6343 }
6344
6345 #[test]
6346 fn test_runtime_rate_target_rejects_zero() {
6347 let txt = r#"(
6348 tasks: [(id: "src", type: "a"), (id: "sink", type: "b")],
6349 cnx: [(src: "src", dst: "sink", msg: "msg::A")],
6350 runtime: (rate_target_hz: 0)
6351 )"#;
6352
6353 let err =
6354 read_configuration_str(txt.to_string(), None).expect_err("runtime config should fail");
6355 assert!(
6356 err.to_string()
6357 .contains("Runtime rate target cannot be zero"),
6358 "unexpected error: {err}"
6359 );
6360 }
6361
6362 #[test]
6363 fn test_runtime_rate_target_rejects_above_nanosecond_resolution() {
6364 let txt = format!(
6365 r#"(
6366 tasks: [(id: "src", type: "a"), (id: "sink", type: "b")],
6367 cnx: [(src: "src", dst: "sink", msg: "msg::A")],
6368 runtime: (rate_target_hz: {})
6369 )"#,
6370 MAX_RATE_TARGET_HZ + 1
6371 );
6372
6373 let err = read_configuration_str(txt, None).expect_err("runtime config should fail");
6374 assert!(
6375 err.to_string().contains("exceeds the supported maximum"),
6376 "unexpected error: {err}"
6377 );
6378 }
6379
6380 fn anytime_config_txt(policy: &str, node_attrs: &str, top_level: &str) -> String {
6384 format!(
6385 r#"(
6386 tasks: [
6387 (id: "src", type: "a"),
6388 (id: "any", type: "b", anytime: ({policy}){node_attrs}),
6389 (id: "sink", type: "c"),
6390 ],
6391 cnx: [
6392 (src: "src", dst: "any", msg: "msg::A"),
6393 (src: "any", dst: "sink", msg: "msg::B"),
6394 ],
6395 {top_level}
6396 )"#
6397 )
6398 }
6399
6400 fn expect_anytime_error(txt: String, expected: &str) {
6401 let err = read_configuration_str(txt, None).expect_err("anytime config should fail");
6402 assert!(
6403 err.to_string().contains(expected),
6404 "unexpected error: {err}"
6405 );
6406 }
6407
6408 #[test]
6409 fn test_anytime_node_parses_and_exposes_policy() {
6410 let txt = anytime_config_txt(
6411 r#"
6412 time_budget_ms: 8.0,
6413 max_age_ms: 100.0,
6414 quality_target: 0.95,
6415 quality_floor: 0.30,
6416 max_refines: 64,
6417 max_stall: 4,
6418 "#,
6419 ", background: true",
6420 "",
6421 );
6422 let config = read_configuration_str(txt, None).unwrap();
6423 let graph = config.get_graph(None).unwrap();
6424 let node = graph
6425 .get_node(graph.get_node_id_by_name("any").unwrap())
6426 .unwrap();
6427 assert!(node.is_anytime());
6428 assert!(node.is_background());
6429 assert_eq!(
6430 node.anytime().unwrap(),
6431 &AnytimeConfig {
6432 time_budget_ms: Some(8.0),
6433 max_age_ms: Some(100.0),
6434 quality_target: Some(0.95),
6435 quality_floor: Some(0.30),
6436 max_refines: Some(64),
6437 max_stall: Some(4),
6438 }
6439 );
6440 let src = graph
6441 .get_node(graph.get_node_id_by_name("src").unwrap())
6442 .unwrap();
6443 assert!(!src.is_anytime());
6444 assert!(src.anytime().is_none());
6445 }
6446
6447 #[test]
6448 fn test_anytime_typical_perception_config_is_accepted() {
6449 let txt = anytime_config_txt(
6452 "max_age_ms: 100.0, quality_target: 0.9, max_refines: 22",
6453 "",
6454 "",
6455 );
6456 let config = read_configuration_str(txt, None).unwrap();
6457 let graph = config.get_graph(None).unwrap();
6458 let node = graph
6459 .get_node(graph.get_node_id_by_name("any").unwrap())
6460 .unwrap();
6461 let anytime = node.anytime().unwrap();
6462 assert_eq!(anytime.max_age_ms, Some(100.0));
6463 assert_eq!(anytime.quality_target, Some(0.9));
6464 assert_eq!(anytime.max_refines, Some(22));
6465 assert_eq!(anytime.time_budget_ms, None);
6466 }
6467
6468 #[test]
6469 fn test_anytime_arity_is_one_input_one_output() {
6470 let two_inputs = r#"(
6472 tasks: [
6473 (id: "src_a", type: "a"),
6474 (id: "src_b", type: "a"),
6475 (id: "any", type: "b", anytime: (max_refines: 2)),
6476 (id: "sink", type: "c"),
6477 ],
6478 cnx: [
6479 (src: "src_a", dst: "any", msg: "msg::A"),
6480 (src: "src_b", dst: "any", msg: "msg::A"),
6481 (src: "any", dst: "sink", msg: "msg::B"),
6482 ],
6483 )"#;
6484 expect_anytime_error(
6485 two_inputs.to_string(),
6486 "exactly one input connection (found 2)",
6487 );
6488
6489 let two_outputs = r#"(
6491 tasks: [
6492 (id: "src", type: "a"),
6493 (id: "any", type: "b", anytime: (max_refines: 2)),
6494 (id: "sink_a", type: "c"),
6495 (id: "sink_b", type: "c"),
6496 ],
6497 cnx: [
6498 (src: "src", dst: "any", msg: "msg::A"),
6499 (src: "any", dst: "sink_a", msg: "msg::B"),
6500 (src: "any", dst: "sink_b", msg: "msg::C"),
6501 ],
6502 )"#;
6503 expect_anytime_error(
6504 two_outputs.to_string(),
6505 "exactly one output message type (found 2)",
6506 );
6507
6508 let fan_out = r#"(
6510 tasks: [
6511 (id: "src", type: "a"),
6512 (id: "any", type: "b", anytime: (max_refines: 2)),
6513 (id: "sink_a", type: "c"),
6514 (id: "sink_b", type: "c"),
6515 ],
6516 cnx: [
6517 (src: "src", dst: "any", msg: "msg::A"),
6518 (src: "any", dst: "sink_a", msg: "msg::B"),
6519 (src: "any", dst: "sink_b", msg: "msg::B"),
6520 ],
6521 )"#;
6522 read_configuration_str(fan_out.to_string(), None).unwrap();
6523 }
6524
6525 #[test]
6526 fn test_anytime_foreground_needs_max_refines() {
6527 expect_anytime_error(
6529 anytime_config_txt("max_age_ms: 100.0, quality_target: 0.9", "", ""),
6530 "needs anytime.max_refines",
6531 );
6532 let background = anytime_config_txt("max_age_ms: 100.0", ", background: true", "");
6534 read_configuration_str(background, None).unwrap();
6535 }
6536
6537 #[test]
6538 fn test_anytime_survives_serialize_roundtrip() {
6539 let txt = anytime_config_txt("time_budget_ms: 8.0, max_refines: 64", "", "");
6540 let config = CuConfig::deserialize_ron(&txt).unwrap();
6541 let serialized = config.serialize_ron().unwrap();
6542 let deserialized = CuConfig::deserialize_ron(&serialized).unwrap();
6543 let graph = deserialized.get_graph(None).unwrap();
6544 let node = graph
6545 .get_node(graph.get_node_id_by_name("any").unwrap())
6546 .unwrap();
6547 assert_eq!(
6548 node.anytime().unwrap(),
6549 &AnytimeConfig {
6550 time_budget_ms: Some(8.0),
6551 max_age_ms: None,
6552 quality_target: None,
6553 quality_floor: None,
6554 max_refines: Some(64),
6555 max_stall: None,
6556 }
6557 );
6558 }
6559
6560 #[test]
6561 fn test_anytime_rejects_missing_hard_bound() {
6562 expect_anytime_error(
6563 anytime_config_txt("quality_target: 0.9, max_stall: 4", "", ""),
6564 "needs at least one hard bound",
6565 );
6566 }
6567
6568 #[test]
6569 fn test_anytime_rejects_nan_quality_target() {
6570 expect_anytime_error(
6571 anytime_config_txt("time_budget_ms: 8.0, quality_target: NaN", "", ""),
6572 "anytime.quality_target must be within (0.0, 1.0]",
6573 );
6574 }
6575
6576 #[test]
6577 fn test_anytime_rejects_non_positive_times() {
6578 expect_anytime_error(
6579 anytime_config_txt("time_budget_ms: 0.0", "", ""),
6580 "anytime.time_budget_ms must be a positive",
6581 );
6582 expect_anytime_error(
6583 anytime_config_txt("max_age_ms: -5.0", "", ""),
6584 "anytime.max_age_ms must be a positive",
6585 );
6586 expect_anytime_error(
6587 anytime_config_txt("time_budget_ms: inf", "", ""),
6588 "anytime.time_budget_ms must be a positive",
6589 );
6590 }
6591
6592 #[test]
6593 fn test_anytime_rejects_zero_counts() {
6594 expect_anytime_error(
6595 anytime_config_txt("max_refines: 0", "", ""),
6596 "anytime.max_refines must be at least 1",
6597 );
6598 expect_anytime_error(
6599 anytime_config_txt("max_refines: 4, max_stall: 0", "", ""),
6600 "anytime.max_stall must be at least 1",
6601 );
6602 }
6603
6604 #[test]
6605 fn test_anytime_quality_ranges() {
6606 let ok = anytime_config_txt(
6608 "time_budget_ms: 8.0, quality_target: 1.0, max_refines: 4",
6609 "",
6610 "",
6611 );
6612 read_configuration_str(ok, None).unwrap();
6613 expect_anytime_error(
6614 anytime_config_txt("time_budget_ms: 8.0, quality_target: 0.0", "", ""),
6615 "anytime.quality_target must be within (0.0, 1.0]",
6616 );
6617 expect_anytime_error(
6619 anytime_config_txt("time_budget_ms: 8.0, quality_floor: 1.0", "", ""),
6620 "anytime.quality_floor must be within (0.0, 1.0)",
6621 );
6622 }
6623
6624 #[test]
6625 fn test_anytime_rejects_floor_above_target() {
6626 expect_anytime_error(
6627 anytime_config_txt(
6628 "time_budget_ms: 8.0, quality_target: 0.5, quality_floor: 0.8",
6629 "",
6630 "",
6631 ),
6632 "must not exceed anytime.quality_target",
6633 );
6634 }
6635
6636 #[test]
6637 fn test_anytime_rejects_sources_and_sinks() {
6638 let on_source = r#"(
6639 tasks: [
6640 (id: "src", type: "a", anytime: (max_refines: 4)),
6641 (id: "sink", type: "b"),
6642 ],
6643 cnx: [(src: "src", dst: "sink", msg: "msg::A")],
6644 )"#;
6645 expect_anytime_error(on_source.to_string(), "only supported on regular tasks");
6646
6647 let on_sink = r#"(
6648 tasks: [
6649 (id: "src", type: "a"),
6650 (id: "sink", type: "b", anytime: (max_refines: 4)),
6651 ],
6652 cnx: [(src: "src", dst: "sink", msg: "msg::A")],
6653 )"#;
6654 expect_anytime_error(on_sink.to_string(), "only supported on regular tasks");
6655 }
6656
6657 #[test]
6658 fn test_anytime_foreground_rate_limited_needs_time_bound() {
6659 expect_anytime_error(
6660 anytime_config_txt("max_refines: 64", "", "runtime: (rate_target_hz: 100),"),
6661 "needs a time bound",
6662 );
6663 }
6664
6665 #[test]
6666 fn test_anytime_foreground_window_must_fit_period() {
6667 expect_anytime_error(
6668 anytime_config_txt(
6669 "time_budget_ms: 12.0, max_refines: 8",
6670 "",
6671 "runtime: (rate_target_hz: 100),",
6672 ),
6673 "does not fit within",
6674 );
6675 let ok = anytime_config_txt(
6677 "time_budget_ms: 20.0, max_age_ms: 5.0, max_refines: 8",
6678 "",
6679 "runtime: (rate_target_hz: 100),",
6680 );
6681 read_configuration_str(ok, None).unwrap();
6682 }
6683
6684 #[test]
6685 fn test_anytime_background_exempt_from_fit_check() {
6686 let txt = anytime_config_txt(
6687 "max_refines: 64",
6688 ", background: true",
6689 "runtime: (rate_target_hz: 100),",
6690 );
6691 read_configuration_str(txt, None).unwrap();
6692 }
6693
6694 #[test]
6695 fn test_anytime_no_rate_target_accepts_refines_only_foreground() {
6696 let txt = anytime_config_txt("max_refines: 64", "", "");
6697 read_configuration_str(txt, None).unwrap();
6698 }
6699
6700 #[test]
6701 fn test_anytime_validated_per_mission_graph() {
6702 let txt = r#"(
6703 missions: [(id: "A"), (id: "B")],
6704 tasks: [
6705 (id: "src", type: "a"),
6706 (id: "any", type: "b", missions: ["B"], anytime: (quality_target: 0.9)),
6707 (id: "sink", type: "c"),
6708 ],
6709 cnx: [
6710 (src: "src", dst: "any", msg: "msg::A", missions: ["B"]),
6711 (src: "any", dst: "sink", msg: "msg::B", missions: ["B"]),
6712 (src: "src", dst: "sink", msg: "msg::A", missions: ["A"]),
6713 ],
6714 )"#;
6715 expect_anytime_error(txt.to_string(), "needs at least one hard bound");
6716 }
6717
6718 #[test]
6719 fn test_nc_connection_marks_source_output_without_creating_edge() {
6720 let txt = r#"(
6721 tasks: [(id: "src", type: "a"), (id: "sink", type: "b")],
6722 cnx: [
6723 (src: "src", dst: "sink", msg: "msg::A"),
6724 (src: "src", dst: "__nc__", msg: "msg::B"),
6725 ]
6726 )"#;
6727 let config = CuConfig::deserialize_ron(txt).unwrap();
6728 let graph = config.get_graph(None).unwrap();
6729 let src_id = graph.get_node_id_by_name("src").unwrap();
6730 let src_node = graph.get_node(src_id).unwrap();
6731
6732 assert_eq!(graph.edge_count(), 1);
6733 assert_eq!(src_node.nc_outputs(), &["msg::B".to_string()]);
6734 }
6735
6736 #[test]
6737 fn test_nc_connection_survives_serialize_roundtrip() {
6738 let txt = r#"(
6739 tasks: [(id: "src", type: "a"), (id: "sink", type: "b")],
6740 cnx: [
6741 (src: "src", dst: "sink", msg: "msg::A"),
6742 (src: "src", dst: "__nc__", msg: "msg::B"),
6743 ]
6744 )"#;
6745 let config = CuConfig::deserialize_ron(txt).unwrap();
6746 let serialized = config.serialize_ron().unwrap();
6747 let deserialized = CuConfig::deserialize_ron(&serialized).unwrap();
6748 let graph = deserialized.get_graph(None).unwrap();
6749 let src_id = graph.get_node_id_by_name("src").unwrap();
6750 let src_node = graph.get_node(src_id).unwrap();
6751
6752 assert_eq!(graph.edge_count(), 1);
6753 assert_eq!(src_node.nc_outputs(), &["msg::B".to_string()]);
6754 }
6755
6756 #[test]
6757 fn test_nc_connection_preserves_original_connection_order() {
6758 let txt = r#"(
6759 tasks: [(id: "src", type: "a"), (id: "sink", type: "b")],
6760 cnx: [
6761 (src: "src", dst: "__nc__", msg: "msg::A"),
6762 (src: "src", dst: "sink", msg: "msg::B"),
6763 ]
6764 )"#;
6765 let config = CuConfig::deserialize_ron(txt).unwrap();
6766 let graph = config.get_graph(None).unwrap();
6767 let src_id = graph.get_node_id_by_name("src").unwrap();
6768 let src_node = graph.get_node(src_id).unwrap();
6769 let edge_id = graph.get_src_edges(src_id).unwrap()[0];
6770 let edge = graph.edge(edge_id).unwrap();
6771
6772 assert_eq!(edge.msg, "msg::B");
6773 assert_eq!(edge.order, 1);
6774 assert_eq!(
6775 src_node
6776 .nc_outputs_with_order()
6777 .map(|(msg, order)| (msg.as_str(), order))
6778 .collect::<Vec<_>>(),
6779 vec![("msg::A", 0)]
6780 );
6781 }
6782
6783 #[cfg(feature = "std")]
6784 fn multi_config_test_dir(name: &str) -> PathBuf {
6785 let unique = std::time::SystemTime::now()
6786 .duration_since(std::time::UNIX_EPOCH)
6787 .expect("system time before unix epoch")
6788 .as_nanos();
6789 let dir = std::env::temp_dir().join(format!("cu29_multi_config_{name}_{unique}"));
6790 std::fs::create_dir_all(&dir).expect("create temp test dir");
6791 dir
6792 }
6793
6794 #[cfg(feature = "std")]
6795 fn write_multi_config_file(dir: &Path, name: &str, contents: &str) -> PathBuf {
6796 let path = dir.join(name);
6797 std::fs::write(&path, contents).expect("write temp config file");
6798 path
6799 }
6800
6801 #[cfg(feature = "std")]
6802 fn alpha_subsystem_config() -> &'static str {
6803 r#"(
6804 tasks: [
6805 (id: "src", type: "demo::Src"),
6806 (id: "sink", type: "demo::Sink"),
6807 ],
6808 bridges: [
6809 (
6810 id: "zenoh",
6811 type: "demo::ZenohBridge",
6812 channels: [
6813 Tx(id: "ping"),
6814 Rx(id: "pong"),
6815 ],
6816 ),
6817 ],
6818 cnx: [
6819 (src: "src", dst: "zenoh/ping", msg: "demo::Ping"),
6820 (src: "zenoh/pong", dst: "sink", msg: "demo::Pong"),
6821 ],
6822 )"#
6823 }
6824
6825 #[cfg(feature = "std")]
6826 fn beta_subsystem_config() -> &'static str {
6827 r#"(
6828 tasks: [
6829 (id: "responder", type: "demo::Responder"),
6830 ],
6831 bridges: [
6832 (
6833 id: "zenoh",
6834 type: "demo::ZenohBridge",
6835 channels: [
6836 Rx(id: "ping"),
6837 Tx(id: "pong"),
6838 ],
6839 ),
6840 ],
6841 cnx: [
6842 (src: "zenoh/ping", dst: "responder", msg: "demo::Ping"),
6843 (src: "responder", dst: "zenoh/pong", msg: "demo::Pong"),
6844 ],
6845 )"#
6846 }
6847
6848 #[cfg(feature = "std")]
6849 fn instance_override_subsystem_config() -> &'static str {
6850 r#"(
6851 tasks: [
6852 (
6853 id: "imu",
6854 type: "demo::ImuTask",
6855 config: {
6856 "sample_hz": 200,
6857 },
6858 ),
6859 ],
6860 resources: [
6861 (
6862 id: "board",
6863 provider: "demo::BoardBundle",
6864 config: {
6865 "bus": "i2c-1",
6866 },
6867 ),
6868 ],
6869 bridges: [
6870 (
6871 id: "radio",
6872 type: "demo::RadioBridge",
6873 config: {
6874 "mtu": 32,
6875 },
6876 channels: [
6877 Tx(id: "tx"),
6878 Rx(id: "rx"),
6879 ],
6880 ),
6881 ],
6882 cnx: [
6883 (src: "imu", dst: "radio/tx", msg: "demo::Packet"),
6884 (src: "radio/rx", dst: "imu", msg: "demo::Packet"),
6885 ],
6886 )"#
6887 }
6888
6889 #[cfg(feature = "std")]
6890 #[test]
6891 fn test_read_multi_configuration_assigns_stable_subsystem_codes() {
6892 let dir = multi_config_test_dir("stable_ids");
6893 write_multi_config_file(&dir, "alpha.ron", alpha_subsystem_config());
6894 write_multi_config_file(&dir, "beta.ron", beta_subsystem_config());
6895 let network_path = write_multi_config_file(
6896 &dir,
6897 "network.ron",
6898 r#"(
6899 subsystems: [
6900 (id: "beta", config: "beta.ron"),
6901 (id: "alpha", config: "alpha.ron"),
6902 ],
6903 interconnects: [
6904 (from: "alpha/zenoh/ping", to: "beta/zenoh/ping", msg: "demo::Ping"),
6905 (from: "beta/zenoh/pong", to: "alpha/zenoh/pong", msg: "demo::Pong"),
6906 ],
6907 )"#,
6908 );
6909
6910 let config =
6911 read_multi_configuration(network_path.to_str().expect("network path utf8")).unwrap();
6912
6913 let alpha = config.subsystem("alpha").expect("alpha subsystem missing");
6914 let beta = config.subsystem("beta").expect("beta subsystem missing");
6915 assert_eq!(alpha.subsystem_code, 0);
6916 assert_eq!(beta.subsystem_code, 1);
6917 assert_eq!(config.interconnects.len(), 2);
6918 assert_eq!(config.interconnects[0].bridge_type, "demo::ZenohBridge");
6919 }
6920
6921 #[cfg(feature = "std")]
6922 #[test]
6923 fn test_multi_configuration_filters_interconnects_by_feature() {
6924 let dir = multi_config_test_dir("feature_interconnects");
6925 write_multi_config_file(&dir, "alpha.ron", alpha_subsystem_config());
6926 write_multi_config_file(&dir, "beta.ron", beta_subsystem_config());
6927 let network_path = write_multi_config_file(
6928 &dir,
6929 "network.ron",
6930 r#"(
6931 subsystems: [
6932 (id: "alpha", config: "alpha.ron"),
6933 (id: "beta", config: "beta.ron"),
6934 ],
6935 interconnects: [
6936 (
6937 from: "alpha/zenoh/ping",
6938 to: "beta/zenoh/ping",
6939 msg: "demo::Ping",
6940 when: Feature("networked"),
6941 ),
6942 (
6943 from: "beta/zenoh/pong",
6944 to: "alpha/zenoh/pong",
6945 msg: "demo::Pong",
6946 when: Feature("networked"),
6947 ),
6948 ],
6949 )"#,
6950 );
6951
6952 let disconnected = read_multi_configuration_with_features(
6953 network_path.to_str().expect("network path utf8"),
6954 &[],
6955 )
6956 .unwrap();
6957 assert!(disconnected.interconnects.is_empty());
6958
6959 let networked = read_multi_configuration_with_features(
6960 network_path.to_str().expect("network path utf8"),
6961 &["networked"],
6962 )
6963 .unwrap();
6964 assert_eq!(networked.interconnects.len(), 2);
6965 }
6966
6967 #[cfg(feature = "std")]
6968 #[test]
6969 fn test_multi_configuration_uses_default_mission_contracts() {
6970 let dir = multi_config_test_dir("default_mission");
6971 write_multi_config_file(
6972 &dir,
6973 "alpha.ron",
6974 r#"(
6975 missions: [(id: "default"), (id: "diagnostics")],
6976 tasks: [
6977 (id: "src", type: "demo::Src"),
6978 (
6979 id: "diagnostic",
6980 type: "demo::Diagnostic",
6981 missions: ["diagnostics"],
6982 ),
6983 ],
6984 bridges: [
6985 (
6986 id: "zenoh",
6987 type: "demo::ZenohBridge",
6988 channels: [Tx(id: "ping")],
6989 ),
6990 ],
6991 cnx: [
6992 (src: "src", dst: "zenoh/ping", msg: "demo::Ping"),
6993 (
6994 src: "diagnostic",
6995 dst: "__nc__",
6996 msg: "demo::DiagnosticMessage",
6997 missions: ["diagnostics"],
6998 ),
6999 ],
7000 )"#,
7001 );
7002 write_multi_config_file(&dir, "beta.ron", beta_subsystem_config());
7003 let network_path = write_multi_config_file(
7004 &dir,
7005 "network.ron",
7006 r#"(
7007 subsystems: [
7008 (id: "alpha", config: "alpha.ron"),
7009 (id: "beta", config: "beta.ron"),
7010 ],
7011 interconnects: [
7012 (from: "alpha/zenoh/ping", to: "beta/zenoh/ping", msg: "demo::Ping"),
7013 ],
7014 )"#,
7015 );
7016
7017 let config =
7018 read_multi_configuration(network_path.to_str().expect("network path utf8")).unwrap();
7019 assert_eq!(config.interconnects.len(), 1);
7020 }
7021
7022 #[cfg(feature = "std")]
7023 #[test]
7024 fn test_read_multi_configuration_rejects_wrong_direction() {
7025 let dir = multi_config_test_dir("wrong_direction");
7026 write_multi_config_file(&dir, "alpha.ron", alpha_subsystem_config());
7027 write_multi_config_file(&dir, "beta.ron", beta_subsystem_config());
7028 let network_path = write_multi_config_file(
7029 &dir,
7030 "network.ron",
7031 r#"(
7032 subsystems: [
7033 (id: "alpha", config: "alpha.ron"),
7034 (id: "beta", config: "beta.ron"),
7035 ],
7036 interconnects: [
7037 (from: "alpha/zenoh/pong", to: "beta/zenoh/ping", msg: "demo::Pong"),
7038 ],
7039 )"#,
7040 );
7041
7042 let err = read_multi_configuration(network_path.to_str().expect("network path utf8"))
7043 .expect_err("direction mismatch should fail");
7044
7045 assert!(
7046 err.to_string()
7047 .contains("must reference a Tx bridge channel"),
7048 "unexpected error: {err}"
7049 );
7050 }
7051
7052 #[cfg(feature = "std")]
7053 #[test]
7054 fn test_read_multi_configuration_rejects_declared_message_mismatch() {
7055 let dir = multi_config_test_dir("msg_mismatch");
7056 write_multi_config_file(&dir, "alpha.ron", alpha_subsystem_config());
7057 write_multi_config_file(&dir, "beta.ron", beta_subsystem_config());
7058 let network_path = write_multi_config_file(
7059 &dir,
7060 "network.ron",
7061 r#"(
7062 subsystems: [
7063 (id: "alpha", config: "alpha.ron"),
7064 (id: "beta", config: "beta.ron"),
7065 ],
7066 interconnects: [
7067 (from: "alpha/zenoh/ping", to: "beta/zenoh/ping", msg: "demo::Wrong"),
7068 ],
7069 )"#,
7070 );
7071
7072 let err = read_multi_configuration(network_path.to_str().expect("network path utf8"))
7073 .expect_err("message mismatch should fail");
7074
7075 assert!(
7076 err.to_string()
7077 .contains("declares message type 'demo::Wrong'"),
7078 "unexpected error: {err}"
7079 );
7080 }
7081
7082 #[cfg(feature = "std")]
7083 #[test]
7084 fn test_read_multi_configuration_resolves_instance_override_root() {
7085 let dir = multi_config_test_dir("instance_root");
7086 write_multi_config_file(&dir, "robot.ron", instance_override_subsystem_config());
7087 let network_path = write_multi_config_file(
7088 &dir,
7089 "multi_copper.ron",
7090 r#"(
7091 subsystems: [
7092 (id: "robot", config: "robot.ron"),
7093 ],
7094 interconnects: [],
7095 instance_overrides_root: "instances",
7096 )"#,
7097 );
7098
7099 let config =
7100 read_multi_configuration(network_path.to_str().expect("network path utf8")).unwrap();
7101
7102 assert_eq!(
7103 config.instance_overrides_root.as_deref().map(Path::new),
7104 Some(dir.join("instances").as_path())
7105 );
7106 }
7107
7108 #[cfg(feature = "std")]
7109 #[test]
7110 fn test_resolve_subsystem_config_for_instance_applies_overrides() {
7111 let dir = multi_config_test_dir("instance_apply");
7112 write_multi_config_file(&dir, "robot.ron", instance_override_subsystem_config());
7113 let instances_dir = dir.join("instances").join("17");
7114 std::fs::create_dir_all(&instances_dir).expect("create instance dir");
7115 write_multi_config_file(
7116 &instances_dir,
7117 "robot.ron",
7118 r#"(
7119 set: [
7120 (
7121 path: "tasks/imu/config",
7122 value: {
7123 "gyro_bias": [0.1, -0.2, 0.3],
7124 },
7125 ),
7126 (
7127 path: "resources/board/config",
7128 value: {
7129 "bus": "robot17-imu",
7130 },
7131 ),
7132 (
7133 path: "bridges/radio/config",
7134 value: {
7135 "mtu": 64,
7136 },
7137 ),
7138 ],
7139 )"#,
7140 );
7141 let network_path = write_multi_config_file(
7142 &dir,
7143 "multi_copper.ron",
7144 r#"(
7145 subsystems: [
7146 (id: "robot", config: "robot.ron"),
7147 ],
7148 interconnects: [],
7149 instance_overrides_root: "instances",
7150 )"#,
7151 );
7152
7153 let multi =
7154 read_multi_configuration(network_path.to_str().expect("network path utf8")).unwrap();
7155 let effective = multi
7156 .resolve_subsystem_config_for_instance("robot", 17)
7157 .expect("effective config");
7158
7159 let graph = effective.get_graph(None).expect("graph");
7160 let imu_id = graph.get_node_id_by_name("imu").expect("imu node");
7161 let imu = graph.get_node(imu_id).expect("imu weight");
7162 let imu_cfg = imu.get_instance_config().expect("imu config");
7163 assert_eq!(imu_cfg.get::<u64>("sample_hz").unwrap(), Some(200));
7164 let gyro_bias: Vec<f64> = imu_cfg
7165 .get_value("gyro_bias")
7166 .expect("gyro_bias deserialize")
7167 .expect("gyro_bias value");
7168 assert_eq!(gyro_bias, vec![0.1, -0.2, 0.3]);
7169
7170 let board = effective
7171 .resources
7172 .iter()
7173 .find(|resource| resource.id == "board")
7174 .expect("board resource");
7175 assert_eq!(
7176 board.config.as_ref().unwrap().get::<String>("bus").unwrap(),
7177 Some("robot17-imu".to_string())
7178 );
7179
7180 let radio = effective
7181 .bridges
7182 .iter()
7183 .find(|bridge| bridge.id == "radio")
7184 .expect("radio bridge");
7185 assert_eq!(
7186 radio.config.as_ref().unwrap().get::<u64>("mtu").unwrap(),
7187 Some(64)
7188 );
7189
7190 let radio_id = graph.get_node_id_by_name("radio").expect("radio node");
7191 let radio_node = graph.get_node(radio_id).expect("radio weight");
7192 assert_eq!(
7193 radio_node
7194 .get_instance_config()
7195 .unwrap()
7196 .get::<u64>("mtu")
7197 .unwrap(),
7198 Some(64)
7199 );
7200 }
7201
7202 #[cfg(feature = "std")]
7203 #[test]
7204 fn test_resolve_subsystem_config_for_instance_rejects_unknown_path() {
7205 let dir = multi_config_test_dir("instance_unknown");
7206 write_multi_config_file(&dir, "robot.ron", instance_override_subsystem_config());
7207 let instances_dir = dir.join("instances").join("17");
7208 std::fs::create_dir_all(&instances_dir).expect("create instance dir");
7209 write_multi_config_file(
7210 &instances_dir,
7211 "robot.ron",
7212 r#"(
7213 set: [
7214 (
7215 path: "tasks/missing/config",
7216 value: {
7217 "gyro_bias": [1.0, 2.0, 3.0],
7218 },
7219 ),
7220 ],
7221 )"#,
7222 );
7223 let network_path = write_multi_config_file(
7224 &dir,
7225 "multi_copper.ron",
7226 r#"(
7227 subsystems: [
7228 (id: "robot", config: "robot.ron"),
7229 ],
7230 interconnects: [],
7231 instance_overrides_root: "instances",
7232 )"#,
7233 );
7234
7235 let multi =
7236 read_multi_configuration(network_path.to_str().expect("network path utf8")).unwrap();
7237 let err = multi
7238 .resolve_subsystem_config_for_instance("robot", 17)
7239 .expect_err("unknown task override should fail");
7240
7241 assert!(
7242 err.to_string().contains("targets unknown task 'missing'"),
7243 "unexpected error: {err}"
7244 );
7245 }
7246
7247 #[test]
7248 fn test_thread_pools_parse_and_round_trip() {
7249 let txt = r#"(
7250 runtime: (
7251 rate_target_hz: 1000,
7252 thread_pools: [
7253 ( id: "rt", threads: 4, affinity: [2, 3, 4, 5], policy: Fifo(priority: 80) ),
7254 ( id: "background", threads: 2, affinity: [0, 1] ),
7255 ( id: "vision", threads: 2, policy: Nice(10), on_error: Strict ),
7256 ],
7257 ),
7258 tasks: [ ( id: "t", type: "tasks::Foo" ) ],
7259 )"#;
7260 let config = CuConfig::deserialize_ron(txt).unwrap();
7261 let runtime = config.runtime.as_ref().expect("runtime config");
7262 assert_eq!(runtime.thread_pools.len(), 3);
7263
7264 let rt = &runtime.thread_pools[0];
7265 assert_eq!(rt.id, "rt");
7266 assert_eq!(rt.threads, 4);
7267 assert_eq!(rt.affinity.as_deref(), Some([2, 3, 4, 5].as_slice()));
7268 assert_eq!(rt.policy, SchedulingPolicy::Fifo { priority: 80 });
7269 assert_eq!(rt.on_error, OnError::Warn);
7270
7271 let bg = &runtime.thread_pools[1];
7272 assert_eq!(bg.id, "background");
7273 assert_eq!(bg.policy, SchedulingPolicy::Fair);
7274
7275 let vision = &runtime.thread_pools[2];
7276 assert_eq!(vision.policy, SchedulingPolicy::Nice(10));
7277 assert_eq!(vision.affinity, None);
7278 assert_eq!(vision.on_error, OnError::Strict);
7279
7280 let serialized = config.serialize_ron().unwrap();
7282 let reparsed = CuConfig::deserialize_ron(&serialized).unwrap();
7283 assert_eq!(
7284 reparsed.runtime.as_ref().unwrap().thread_pools,
7285 runtime.thread_pools
7286 );
7287 }
7288
7289 #[test]
7290 fn test_background_flag_and_pool_forms() {
7291 let txt = r#"(
7292 tasks: [
7293 ( id: "a", type: "tasks::Foo", background: true ),
7294 ( id: "b", type: "tasks::Foo", background: (pool: "vision") ),
7295 ( id: "c", type: "tasks::Foo" ),
7296 ],
7297 cnx: [],
7298 )"#;
7299 let config = CuConfig::deserialize_ron(txt).unwrap();
7300 let graph = config.get_graph(None).unwrap();
7301
7302 let a = graph.get_node(0).unwrap();
7303 assert!(a.is_background());
7304 assert_eq!(a.background_pool(), DEFAULT_BACKGROUND_POOL);
7305
7306 let b = graph.get_node(1).unwrap();
7307 assert!(b.is_background());
7308 assert_eq!(b.background_pool(), "vision");
7309
7310 let c = graph.get_node(2).unwrap();
7311 assert!(!c.is_background());
7312 assert_eq!(c.background_pool(), DEFAULT_BACKGROUND_POOL);
7313 }
7314
7315 #[test]
7316 fn test_thread_pool_validation_rejects_bad_configs() {
7317 let cases = [
7318 (
7319 r#"( runtime: ( thread_pools: [ ( id: "rt", threads: 0 ) ] ), tasks: [] )"#,
7320 "at least 1 thread",
7321 ),
7322 (
7323 r#"( runtime: ( thread_pools: [ ( id: "a", threads: 1 ), ( id: "a", threads: 1 ) ] ), tasks: [] )"#,
7324 "Duplicate thread pool id",
7325 ),
7326 (
7327 r#"( runtime: ( thread_pools: [ ( id: "rt", threads: 1, policy: Fifo(priority: 200) ) ] ), tasks: [] )"#,
7328 "out of range",
7329 ),
7330 (
7331 r#"( runtime: ( thread_pools: [ ( id: "rt", threads: 1, affinity: [] ) ] ), tasks: [] )"#,
7332 "empty affinity",
7333 ),
7334 ];
7335
7336 for (txt, expected) in cases {
7337 let err = CuConfig::deserialize_ron(txt)
7338 .expect_err("expected thread pool validation to fail");
7339 assert!(
7340 err.to_string().contains(expected),
7341 "error '{err}' did not contain '{expected}'"
7342 );
7343 }
7344 }
7345
7346 #[cfg(feature = "std")]
7347 #[test]
7348 fn test_default_background_pool_injected_for_background_tasks() {
7349 let txt = r#"(
7350 tasks: [
7351 ( id: "src", type: "tasks::Src" ),
7352 ( id: "bg", type: "tasks::Task", background: true ),
7353 ],
7354 cnx: [
7355 ( src: "src", dst: "bg", msg: "i32" ),
7356 ( src: "bg", dst: "__nc__", msg: "i32" ),
7357 ],
7358 )"#;
7359 let config = read_configuration_str(txt.to_string(), None).unwrap();
7360 let pools = &config.runtime.as_ref().unwrap().thread_pools;
7361 let background: Vec<_> = pools
7362 .iter()
7363 .filter(|p| p.id == DEFAULT_BACKGROUND_POOL)
7364 .collect();
7365 assert_eq!(background.len(), 1);
7366 assert_eq!(background[0].threads, 2);
7367 assert!(!config.resources.iter().any(|b| b.id == "threadpool"));
7370 }
7371}